denberek_obsidian-agent-fleet/src/views/dashboardView.ts

6197 lines
271 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ItemView, MarkdownRenderer, Notice, TFile, WorkspaceLeaf, setIcon } from "obsidian";
import { IconPickerModal } from "../modals/iconPickerModal";
import { VIEW_TYPE_DASHBOARD } from "../constants";
import type AgentFleetPlugin from "../main";
import type { AgentConfig, AgentHealth, ChannelConfig, McpServer, McpTool, RunLogData, SkillConfig, TaskConfig } from "../types";
import { truncate, slugify, parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter } from "../utils/markdown";
import { splitLines } from "../utils/platform";
import { createIcon } from "../utils/icons";
import { renderBarChart, renderDonutChart } from "../components/chartRenderer";
import type { BarChartDay } from "../components/chartRenderer";
import { makeDraggable, makeDropTarget } from "../components/dragDrop";
import { CODEX_MODEL_ALIASES, MODEL_ALIASES, renderModelPicker } from "../components/modelPicker";
import { renderSections } from "../utils/memoryFormat";
import { parseLatestLintReport } from "../utils/wikiLintReport";
type DashboardPage =
| "dashboard"
| "agents"
| "kanban"
| "runs"
| "skills"
| "approvals"
| "mcp"
| "channels"
| "wiki-keepers"
| "agent-detail"
| "task-detail"
| "create-agent"
| "create-task"
| "create-skill"
| "edit-agent"
| "edit-task"
| "edit-skill"
| "create-channel"
| "edit-channel"
| "add-mcp-server";
const PAGE_LABELS: Record<DashboardPage, string> = {
dashboard: "Dashboard",
agents: "Agents",
kanban: "Tasks Board",
runs: "Run History",
skills: "Skills Library",
approvals: "Approvals",
mcp: "MCP Servers",
channels: "Channels",
"wiki-keepers": "Wiki Keepers",
"agent-detail": "Agent Details",
"task-detail": "Task Details",
"create-agent": "Create Agent",
"create-task": "Create Task",
"create-skill": "Create Skill",
"edit-agent": "Edit Agent",
"edit-task": "Edit Task",
"edit-skill": "Edit Skill",
"create-channel": "Create Channel",
"edit-channel": "Edit Channel",
"add-mcp-server": "Add MCP Server",
};
const PAGE_ICON_NAMES: Record<DashboardPage, string> = {
dashboard: "layout-dashboard",
agents: "bot",
kanban: "columns-3",
runs: "scroll-text",
skills: "puzzle",
approvals: "shield-check",
mcp: "plug",
channels: "radio",
"wiki-keepers": "library",
"agent-detail": "bot",
"task-detail": "circle-dot",
"create-agent": "plus",
"create-task": "plus",
"create-skill": "plus",
"edit-agent": "edit",
"edit-task": "edit",
"edit-skill": "edit",
"create-channel": "plus",
"edit-channel": "edit",
"add-mcp-server": "plus",
};
const MAIN_PAGES: DashboardPage[] = ["dashboard", "agents", "kanban", "runs", "approvals", "skills", "wiki-keepers", "mcp", "channels"];
/* Note: The per-adapter model dropdown was replaced by the ModelPicker
* component (src/components/modelPicker.ts). Aliases and custom IDs are
* preferred over hardcoded lists because they work across all backends
* (Anthropic direct, Bedrock, Vertex, Foundry, Mantle) without updates. */
// Adapter choices in the agent forms. "process"/"http" stay greyed out until
// those backends exist.
const ADAPTER_FORM_OPTIONS: Array<[string, string, boolean]> = [
["claude-code", "Claude Code", false],
["codex", "Codex", false],
["process", "Process (coming soon)", true],
["http", "HTTP (coming soon)", true],
];
// Permission modes per adapter: [value, label, description].
// Claude Code uses its native --permission-mode vocabulary; Codex maps onto
// sandbox levels (`codex exec` never prompts, so the sandbox is the only
// enforcement axis).
const CLAUDE_PERM_MODE_OPTIONS: Array<[string, string, string]> = [
["bypassPermissions", "Bypass Permissions", "Auto-approve everything except deny list"],
["dontAsk", "Dont Ask", "Auto-approve all tool calls"],
["acceptEdits", "Accept Edits", "Auto-approve file edits, block bash unless allowed"],
["plan", "Plan", "Read-only mode, no writes or commands"],
["default", "Default", "Ask for each tool call"],
];
const CODEX_PERM_MODE_OPTIONS: Array<[string, string, string]> = [
["bypassPermissions", "Bypass (no sandbox)", "No sandbox, auto-approve everything"],
["workspace-write", "Workspace Write", "Sandboxed: writes only inside the working dir"],
["read-only", "Read Only", "Sandboxed: no writes or side-effect commands"],
];
function isCodexAdapterValue(adapter: string): boolean {
const v = adapter.trim().toLowerCase();
return v === "codex" || v === "openai-codex";
}
function permModeOptionsFor(adapter: string): Array<[string, string, string]> {
return isCodexAdapterValue(adapter) ? CODEX_PERM_MODE_OPTIONS : CLAUDE_PERM_MODE_OPTIONS;
}
/** Translate a permission-mode value to the nearest equivalent when the user
* switches the form's adapter, so the dropdown always shows a valid choice. */
function permModeForAdapter(value: string, adapter: string): string {
if (isCodexAdapterValue(adapter)) {
switch (value) {
case "acceptEdits":
case "default":
return "workspace-write";
case "plan":
return "read-only";
case "dontAsk":
return "bypassPermissions";
default:
return CODEX_PERM_MODE_OPTIONS.some(([v]) => v === value) ? value : "bypassPermissions";
}
}
switch (value) {
case "workspace-write":
return "acceptEdits";
case "read-only":
return "plan";
case "danger-full-access":
return "bypassPermissions";
default:
return CLAUDE_PERM_MODE_OPTIONS.some(([v]) => v === value) ? value : "bypassPermissions";
}
}
export class FleetDashboardView extends ItemView {
private currentPage: DashboardPage = "dashboard";
private detailContext?: string;
private agentDetailTab = "overview";
private streamingUnsubscribes: (() => void)[] = [];
private channelStatusUnsubscribe?: () => void;
private authenticatingServers = new Set<string>();
constructor(
leaf: WorkspaceLeaf,
private readonly plugin: AgentFleetPlugin,
) {
super(leaf);
}
getViewType(): string {
return VIEW_TYPE_DASHBOARD;
}
getDisplayText(): string {
return "Agent Fleet";
}
getIcon(): string {
return "bot";
}
async onOpen(): Promise<void> {
this.plugin.subscribeView(this);
// Live-refresh status dots on the channels page when adapters connect/disconnect.
this.channelStatusUnsubscribe = this.plugin.channelManager?.onStatusChange(() => {
if (this.currentPage === "channels") {
void this.render();
}
});
await this.render();
}
async onClose(): Promise<void> {
this.cleanupStreaming();
this.channelStatusUnsubscribe?.();
this.channelStatusUnsubscribe = undefined;
this.plugin.unsubscribeView(this);
}
navigateTo(page: DashboardPage, context?: string): void {
this.currentPage = page;
this.detailContext = context;
if (page !== "agent-detail") this.agentDetailTab = "overview";
void this.render();
}
async render(): Promise<void> {
this.cleanupStreaming();
const root = this.contentEl;
root.empty();
root.addClass("af-root");
const app = root.createDiv({ cls: "af-app" });
const main = app.createDiv({ cls: "af-main-content" });
this.renderTopBar(main);
this.renderTabBar(main);
const pageContainer = main.createDiv({ cls: "af-page" });
switch (this.currentPage) {
case "dashboard":
this.renderDashboardPage(pageContainer);
break;
case "agents":
this.renderAgentsPage(pageContainer);
break;
case "kanban":
this.renderKanbanPage(pageContainer);
break;
case "runs":
this.renderRunsPage(pageContainer);
break;
case "skills":
this.renderSkillsPage(pageContainer);
break;
case "approvals":
this.renderApprovalsPage(pageContainer);
break;
case "mcp":
this.renderMcpPage(pageContainer);
break;
case "channels":
this.renderChannelsPage(pageContainer);
break;
case "wiki-keepers":
void this.renderWikiKeepersPage(pageContainer);
break;
case "agent-detail":
this.renderAgentDetailPage(pageContainer);
break;
case "task-detail":
this.renderTaskDetailPage(pageContainer);
break;
case "create-agent":
this.renderCreateAgentPage(pageContainer);
break;
case "create-task":
this.renderCreateTaskPage(pageContainer);
break;
case "create-skill":
this.renderCreateSkillPage(pageContainer);
break;
case "edit-agent":
this.renderEditAgentPage(pageContainer);
break;
case "edit-task":
this.renderEditTaskPage(pageContainer);
break;
case "edit-skill":
this.renderEditSkillPage(pageContainer);
break;
case "create-channel":
this.renderCreateChannelPage(pageContainer);
break;
case "edit-channel":
this.renderEditChannelPage(pageContainer);
break;
case "add-mcp-server":
this.renderAddMcpServerPage(pageContainer);
break;
}
}
private navigate(page: DashboardPage, context?: string): void {
this.navigateTo(page, context);
}
private cleanupStreaming(): void {
for (const unsub of this.streamingUnsubscribes) {
unsub();
}
this.streamingUnsubscribes = [];
}
// ═══════════════════════════════════════════════════════
// Top Bar
// ═══════════════════════════════════════════════════════
private renderTopBar(container: HTMLElement): void {
const bar = container.createDiv({ cls: "af-top-bar" });
const title = bar.createDiv({ cls: "af-top-bar-title" });
createIcon(title, "bot", "af-top-bar-icon");
title.createSpan({ text: "Agent Fleet" });
const breadcrumb = bar.createDiv({ cls: "af-breadcrumb" });
const chevron1 = breadcrumb.createSpan({ cls: "af-breadcrumb-sep" });
setIcon(chevron1, "chevron-right");
// Multi-level breadcrumbs for detail/edit pages
const addCrumb = (label: string, page?: DashboardPage, ctx?: string) => {
const el = breadcrumb.createSpan({ cls: page ? "af-breadcrumb-link" : "", text: label });
if (page) el.onclick = () => this.navigate(page, ctx);
};
const addSep = () => {
const s = breadcrumb.createSpan({ cls: "af-breadcrumb-sep" });
setIcon(s, "chevron-right");
};
switch (this.currentPage) {
case "agent-detail":
addCrumb("Agents", "agents");
addSep();
addCrumb(this.detailContext ?? "Agent");
break;
case "task-detail":
addCrumb("Tasks Board", "kanban");
addSep();
addCrumb(this.detailContext ?? "Task");
break;
case "edit-agent":
addCrumb("Agents", "agents");
addSep();
addCrumb(this.detailContext ?? "Agent", "agent-detail", this.detailContext);
addSep();
addCrumb("Edit");
break;
case "edit-task":
addCrumb("Tasks Board", "kanban");
addSep();
addCrumb(this.detailContext ?? "Task", "task-detail", this.detailContext);
addSep();
addCrumb("Edit");
break;
case "create-agent":
addCrumb("Agents", "agents");
addSep();
addCrumb("New Agent");
break;
case "create-task":
addCrumb("Tasks Board", "kanban");
addSep();
addCrumb("New Task");
break;
case "create-skill":
addCrumb("Skills Library", "skills");
addSep();
addCrumb("New Skill");
break;
case "edit-skill":
addCrumb("Skills Library", "skills");
addSep();
addCrumb(this.detailContext ?? "Skill");
addSep();
addCrumb("Edit");
break;
case "create-channel":
addCrumb("Channels", "channels");
addSep();
addCrumb("New Channel");
break;
case "edit-channel":
addCrumb("Channels", "channels");
addSep();
addCrumb(this.detailContext ?? "Channel");
addSep();
addCrumb("Edit");
break;
case "add-mcp-server":
addCrumb("MCP Servers", "mcp");
addSep();
addCrumb("Add Server");
break;
default:
addCrumb(PAGE_LABELS[this.currentPage]);
}
bar.createDiv({ cls: "af-top-bar-spacer" });
// Search
const searchWrap = bar.createDiv({ cls: "af-search-wrap" });
createIcon(searchWrap, "search", "af-search-icon");
const searchInput = searchWrap.createEl("input", {
cls: "af-search-input",
attr: { type: "text", placeholder: "Search agents, tasks, runs..." },
});
searchInput.addEventListener("input", () => {
this.handleSearch(searchInput.value, searchWrap);
});
searchInput.addEventListener("blur", () => {
window.setTimeout(() => searchWrap.querySelector(".af-search-results")?.remove(), 200);
});
// Status pills
const status = this.plugin.runtime.getFleetStatus();
const pills = bar.createDiv({ cls: "af-status-pills" });
if (status.running > 0) {
const pill = pills.createSpan({ cls: "af-pill yellow" });
pill.createSpan({ cls: "af-dot pulse" });
pill.appendText(` ${status.running} Running`);
}
if (status.pending > 0) {
const pill = pills.createSpan({ cls: "af-pill blue" });
pill.createSpan({ cls: "af-dot" });
pill.appendText(` ${status.pending} Pending`);
}
const todayPill = pills.createSpan({ cls: "af-pill green" });
todayPill.createSpan({ cls: "af-dot" });
todayPill.appendText(` ${status.completedToday} Today`);
}
// ═══════════════════════════════════════════════════════
// Tab Bar
// ═══════════════════════════════════════════════════════
private renderTabBar(container: HTMLElement): void {
const bar = container.createDiv({ cls: "af-tab-bar" });
const snapshot = this.plugin.runtime.getSnapshot();
const status = this.plugin.runtime.getFleetStatus();
for (const page of MAIN_PAGES) {
const isActive = this.currentPage === page ||
(page === "agents" && (this.currentPage === "agent-detail" || this.currentPage === "edit-agent" || this.currentPage === "create-agent")) ||
(page === "kanban" && (this.currentPage === "task-detail" || this.currentPage === "edit-task")) ||
(page === "skills" && (this.currentPage === "edit-skill" || this.currentPage === "create-skill")) ||
(page === "mcp" && this.currentPage === "add-mcp-server");
const tab = bar.createEl("button", {
cls: `af-tab-item${isActive ? " active" : ""}`,
});
const tabIcon = tab.createSpan({ cls: "af-tab-icon" });
setIcon(tabIcon, PAGE_ICON_NAMES[page]);
tab.appendText(page === "dashboard" ? "Overview" : PAGE_LABELS[page]);
if (page === "agents") {
tab.createSpan({ cls: "af-badge", text: String(snapshot.agents.length) });
} else if (page === "skills") {
tab.createSpan({ cls: "af-badge", text: String(snapshot.skills.length) });
} else if (page === "mcp") {
const mcpCount = this.plugin.repository.getMcpServers().length;
tab.createSpan({ cls: "af-badge", text: String(mcpCount) });
} else if (page === "approvals" && status.pending > 0) {
tab.createSpan({ cls: "af-badge af-badge-warn", text: String(status.pending) });
}
tab.onclick = () => this.navigate(page);
}
}
// ═══════════════════════════════════════════════════════
// Dashboard (Overview) Page
// ═══════════════════════════════════════════════════════
private renderDashboardPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-dashboard" });
const snapshot = this.plugin.runtime.getSnapshot();
const runs = this.plugin.runtime.getRecentRuns();
const status = this.plugin.runtime.getFleetStatus();
// Approval banners
const pendingApprovals = runs.filter((run) =>
(run.approvals ?? []).some((a) => a.status === "pending"),
);
for (const run of pendingApprovals) {
for (const approval of run.approvals ?? []) {
if (approval.status !== "pending") continue;
this.renderApprovalBanner(page, run, approval.tool);
}
}
// Stat cards
const grid = page.createDiv({ cls: "af-dash-grid" });
const activeAgents = snapshot.agents.filter((a) => a.enabled).length;
const totalAgents = snapshot.agents.length;
this.renderStatCard(grid, "Active Agents", `${activeAgents}`, `/ ${totalAgents}`, "bot",
`${activeAgents} of ${totalAgents} enabled`);
const todayStr = this.toLocalDateStr(new Date());
const todayRuns = runs.filter((r) => this.runToLocalDate(r.started) === todayStr);
const passed = todayRuns.filter((r) => r.status === "success").length;
const failed = todayRuns.filter((r) => r.status === "failure" || r.status === "timeout").length;
this.renderStatCard(grid, "Runs Today", String(todayRuns.length), "", "activity",
`${passed} passed \u00B7 ${failed} failed \u00B7 ${status.running} running`);
const totalTokens = todayRuns.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
const totalCost = todayRuns.reduce((sum, r) => sum + (r.costUsd ?? 0), 0);
const costSuffix = totalCost > 0 ? ` \u00B7 $${totalCost.toFixed(2)}` : "";
this.renderStatCard(grid, "Tokens Used", formatTokenCount(totalTokens), "", "zap", `today${costSuffix}`);
const scheduledTasks = snapshot.tasks.filter((t) => t.enabled && t.schedule);
this.renderStatCard(grid, "Scheduled Tasks", String(scheduledTasks.length), "", "clock",
scheduledTasks.length > 0 ? `Next: ${this.getNextTaskLabel(scheduledTasks)}` : "No scheduled tasks");
// Charts row — bar chart is fed by the date-bounded chartRuns list so
// a busy fleet can't push older days out of the 14-day window. Donut
// (success rate) keeps using the recent-50 list, which is the headline
// "how am I doing lately" number.
this.renderChartsRow(page, runs, this.plugin.runtime.getChartRuns());
// Streaming output cards
this.renderStreamingCards(page);
// Split: Timeline + Fleet status
const split = page.createDiv({ cls: "af-dash-split" });
this.renderActivityTimeline(split, runs);
this.renderFleetStatusPanel(split, snapshot);
}
private renderChartsRow(container: HTMLElement, runs: RunLogData[], chartRuns: RunLogData[]): void {
const row = container.createDiv({ cls: "af-charts-row" });
// Bar chart: 14-day run activity
const barSection = row.createDiv({ cls: "af-section-card af-chart-section" });
const barHeader = barSection.createDiv({ cls: "af-section-header" });
const barTitle = barHeader.createDiv({ cls: "af-section-title" });
createIcon(barTitle, "activity");
barTitle.appendText(" Run Activity (14d)");
const barBody = barSection.createDiv({ cls: "af-chart-body" });
const chartData = this.buildChartData(chartRuns, 14);
if (chartData.some((d) => d.success + d.failure + d.cancelled > 0)) {
renderBarChart(barBody, chartData);
} else {
this.renderEmptyState(barBody, "activity", "No run data", "Run agents to see activity");
}
// Donut chart: success rate
const donutSection = row.createDiv({ cls: "af-section-card af-chart-section" });
const donutHeader = donutSection.createDiv({ cls: "af-section-header" });
const donutTitle = donutHeader.createDiv({ cls: "af-section-title" });
createIcon(donutTitle, "target");
donutTitle.appendText(" Success Rate");
const donutBody = donutSection.createDiv({ cls: "af-chart-body af-chart-body-center" });
const totalRuns = runs.length;
const successRuns = runs.filter((r) => r.status === "success").length;
if (totalRuns > 0) {
renderDonutChart(donutBody, successRuns, totalRuns);
} else {
this.renderEmptyState(donutBody, "target", "No data", "");
}
}
private toLocalDateStr(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
private runToLocalDate(started: string): string {
return this.toLocalDateStr(new Date(started));
}
private buildChartData(runs: RunLogData[], days: number): BarChartDay[] {
const result: BarChartDay[] = [];
const now = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const dateStr = this.toLocalDateStr(d);
const dayRuns = runs.filter((r) => this.runToLocalDate(r.started) === dateStr);
result.push({
date: dateStr,
success: dayRuns.filter((r) => r.status === "success").length,
failure: dayRuns.filter((r) => r.status === "failure" || r.status === "timeout").length,
cancelled: dayRuns.filter((r) => r.status === "cancelled").length,
});
}
return result;
}
private renderStreamingCards(container: HTMLElement): void {
const snapshot = this.plugin.runtime.getSnapshot();
const runningAgents = snapshot.agents.filter((a) => {
const state = this.plugin.runtime.getAgentState(a.name);
return state.status === "running";
});
if (runningAgents.length === 0) return;
const section = container.createDiv({ cls: "af-streaming-section" });
const header = section.createDiv({ cls: "af-section-header" });
const titleEl = header.createDiv({ cls: "af-section-title" });
createIcon(titleEl, "terminal");
titleEl.appendText(" Active Agents");
for (const agent of runningAgents) {
this.renderStreamingCard(section, agent.name);
}
}
private renderStreamingCard(container: HTMLElement, agentName: string): void {
const card = container.createDiv({ cls: "af-streaming-card" });
// Find what this agent is currently running
const state = this.plugin.runtime.getAgentState(agentName);
const snapshot = this.plugin.runtime.getSnapshot();
const runningTask = state.currentTaskId
? snapshot.tasks.find((t) => t.taskId === state.currentTaskId)
: undefined;
const taskLabel = runningTask
? `${runningTask.taskId}`
: state.status === "running" ? " → Heartbeat" : "";
const header = card.createDiv({ cls: "af-streaming-card-header" });
header.createSpan({ cls: "af-dot pulse", attr: { style: "background: var(--af-yellow)" } });
header.createSpan({ cls: "af-streaming-card-agent", text: ` ${agentName}` });
if (taskLabel) {
header.createSpan({ cls: "af-streaming-card-task", text: taskLabel });
}
const output = card.createDiv({ cls: "af-streaming-output" });
const buffer = this.plugin.runtime.getRunOutputBuffer(agentName);
const lines = splitLines(buffer).slice(-4);
output.setText(lines.join("\n"));
const unsub = this.plugin.runtime.onRunOutput(agentName, () => {
const updated = this.plugin.runtime.getRunOutputBuffer(agentName);
const updatedLines = splitLines(updated).slice(-4);
output.setText(updatedLines.join("\n"));
output.scrollTop = output.scrollHeight;
});
this.streamingUnsubscribes.push(unsub);
}
private renderApprovalBanner(container: HTMLElement, run: RunLogData, tool: string): void {
const banner = container.createDiv({ cls: "af-approval-banner" });
const iconEl = banner.createDiv({ cls: "af-approval-icon" });
setIcon(iconEl, "shield-check");
const text = banner.createDiv({ cls: "af-approval-text" });
text.createDiv({ cls: "af-approval-title", text: `${run.agent} wants to run: ${tool}` });
text.createDiv({ cls: "af-approval-desc", text: `Approval required for tool: ${tool}` });
const actions = banner.createDiv({ cls: "af-approval-actions" });
const approveBtn = actions.createEl("button", { cls: "af-btn-approve", text: "Approve" });
approveBtn.onclick = () =>
void this.plugin.runtime.resolveApproval(run, tool, "approved").then(() => this.render());
const rejectBtn = actions.createEl("button", { cls: "af-btn-reject", text: "Reject" });
rejectBtn.onclick = () =>
void this.plugin.runtime.resolveApproval(run, tool, "rejected").then(() => this.render());
}
private renderStatCard(
container: HTMLElement,
label: string,
value: string,
valueSuffix: string,
iconName: string,
sub: string,
): void {
const card = container.createDiv({ cls: "af-stat-card" });
const labelEl = card.createDiv({ cls: "af-stat-label" });
createIcon(labelEl, iconName, "af-stat-icon");
labelEl.appendText(` ${label}`);
const valueEl = card.createDiv({ cls: "af-stat-value" });
valueEl.appendText(value);
if (valueSuffix) {
valueEl.createSpan({ cls: "af-stat-value-suffix", text: valueSuffix });
}
card.createDiv({ cls: "af-stat-sub", text: sub });
}
private renderActivityTimeline(container: HTMLElement, runs: RunLogData[]): void {
const section = container.createDiv({ cls: "af-section-card" });
const header = section.createDiv({ cls: "af-section-header" });
const titleEl = header.createDiv({ cls: "af-section-title" });
createIcon(titleEl, "inbox");
titleEl.appendText(" Recent Activity");
const timeline = section.createDiv({ cls: "af-timeline" });
const displayRuns = runs.slice(0, 8);
if (displayRuns.length === 0) {
this.renderEmptyState(timeline, "inbox", "No runs yet", "Run an agent to see activity here");
return;
}
for (const run of displayRuns) {
this.renderTimelineItem(timeline, run);
}
}
private renderTimelineItem(container: HTMLElement, run: RunLogData): void {
const item = container.createDiv({ cls: "af-timeline-item" });
const iconCls = this.statusToTimelineClass(run.status);
const iconEl = item.createDiv({ cls: `af-tl-icon ${iconCls}` });
setIcon(iconEl, this.statusToIconName(run.status));
const body = item.createDiv({ cls: "af-tl-body" });
const titleEl = body.createDiv({ cls: "af-tl-title" });
titleEl.createSpan({ cls: "af-agent-tag", text: run.agent });
titleEl.appendText(` ${run.task}`);
body.createDiv({ cls: "af-tl-desc", text: truncate(run.output, 100) });
const meta = body.createDiv({ cls: "af-tl-meta" });
const metaTime = meta.createSpan();
createIcon(metaTime, "clock", "af-meta-icon");
metaTime.appendText(` ${this.formatStarted(run.started)} \u00B7 ${this.formatDuration(run.durationSeconds)}`);
if (run.tokensUsed) {
const metaTokens = meta.createSpan();
createIcon(metaTokens, "zap", "af-meta-icon");
metaTokens.appendText(` ${run.tokensUsed.toLocaleString()} tokens`);
}
item.onclick = () => this.openSlideover(run);
}
private renderFleetStatusPanel(
container: HTMLElement,
snapshot: { agents: AgentConfig[]; tasks: TaskConfig[] },
): void {
const section = container.createDiv({ cls: "af-section-card" });
const header = section.createDiv({ cls: "af-section-header" });
const titleEl = header.createDiv({ cls: "af-section-title" });
createIcon(titleEl, "bot");
titleEl.appendText(" Fleet Status");
const headerActions = header.createDiv({ cls: "af-section-actions" });
const newAgentBtn = headerActions.createEl("button", { cls: "af-btn-sm primary" });
createIcon(newAgentBtn, "plus", "af-btn-icon");
newAgentBtn.appendText(" New Agent");
newAgentBtn.onclick = () => void this.plugin.createAgentTemplate();
const list = section.createDiv({ cls: "af-agent-mini-list" });
if (snapshot.agents.length === 0) {
this.renderEmptyState(list, "bot", "No agents yet", "Create your first agent to get started");
return;
}
for (const agent of snapshot.agents) {
this.renderAgentMini(list, agent, snapshot.tasks);
}
// Quick run section
const enabledAgents = snapshot.agents.filter((a) => a.enabled);
if (enabledAgents.length > 0) {
const quickRun = section.createDiv({ cls: "af-quick-run" });
const quickLabel = quickRun.createDiv({ cls: "af-quick-run-label" });
createIcon(quickLabel, "zap", "af-meta-icon");
quickLabel.appendText(" Quick Run");
const row = quickRun.createDiv({ cls: "af-quick-run-row" });
const select = row.createEl("select", { cls: "af-select" });
for (const agent of enabledAgents) {
select.createEl("option", { text: agent.name, attr: { value: agent.name } });
}
const runBtn = row.createEl("button", { cls: "af-btn-sm primary" });
createIcon(runBtn, "play", "af-btn-icon");
runBtn.appendText(" Run");
runBtn.onclick = () => void this.plugin.runAgentPrompt(select.value);
}
}
private renderAgentMini(
container: HTMLElement,
agent: AgentConfig,
tasks: TaskConfig[],
): void {
const state = this.plugin.runtime.getAgentState(agent.name);
const agentTasks = tasks.filter((t) => t.agent === agent.name);
const statusClass = this.healthToClass(state.status);
const item = container.createDiv({ cls: "af-agent-mini" });
const avatar = item.createDiv({ cls: `af-agent-avatar ${statusClass}` });
if (agent.avatar?.trim()) {
this.renderAgentAvatar(avatar, agent);
} else {
avatar.setText(this.getInitials(agent.name));
}
const info = item.createDiv({ cls: "af-agent-info" });
info.createDiv({ cls: "af-agent-name", text: agent.name });
let desc = "";
if (state.status === "running") {
desc = `Running now \u00B7 ${agentTasks.length} task${agentTasks.length !== 1 ? "s" : ""}`;
} else if (!agent.enabled) {
desc = `Disabled \u00B7 ${agentTasks.length} task${agentTasks.length !== 1 ? "s" : ""} paused`;
} else {
const nextRun = agentTasks.map((t) => t.nextRun).filter(Boolean).sort()[0];
desc = nextRun
? `Next: ${this.formatNextRun(nextRun)} \u00B7 ${agentTasks.length} task${agentTasks.length !== 1 ? "s" : ""}`
: `${agentTasks.length} task${agentTasks.length !== 1 ? "s" : ""}`;
}
info.createDiv({ cls: "af-agent-desc", text: desc });
item.createDiv({ cls: `af-agent-status-dot ${statusClass}` });
item.onclick = () => this.navigate("agent-detail", agent.name);
}
// ═══════════════════════════════════════════════════════
// Agents Page
// ═══════════════════════════════════════════════════════
private renderAgentsPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-agents-page" });
const snapshot = this.plugin.runtime.getSnapshot();
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "Agents" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" });
createIcon(newBtn, "plus", "af-btn-icon");
newBtn.appendText(" New Agent");
newBtn.onclick = () => void this.plugin.createAgentTemplate();
const grid = page.createDiv({ cls: "af-agents-grid" });
if (snapshot.agents.length === 0) {
this.renderEmptyState(grid, "bot", "No agents configured", "Create your first agent to get started");
return;
}
for (const agent of snapshot.agents) {
this.renderAgentCard(grid, agent, snapshot);
}
}
private renderAgentCard(
container: HTMLElement,
agent: AgentConfig,
snapshot: { tasks: TaskConfig[]; skills: SkillConfig[] },
): void {
const state = this.plugin.runtime.getAgentState(agent.name);
const agentRuns = this.plugin.runtime.getRecentRuns().filter((r) => r.agent === agent.name);
const card = container.createDiv({ cls: `af-agent-card${agent.enabled ? "" : " disabled"}` });
// Header
const header = card.createDiv({ cls: "af-agent-card-header" });
const avatarCls = agent.enabled ? this.healthToClass(state.status) : "disabled";
const avatar = header.createDiv({ cls: `af-agent-card-avatar ${avatarCls}` });
this.renderAgentAvatar(avatar, agent);
const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" });
const nameRow = titleBlock.createDiv({ cls: "af-agent-card-name" });
nameRow.appendText(agent.name);
if (agent.heartbeatEnabled && agent.heartbeatSchedule) {
const hbIcon = nameRow.createSpan({ cls: "af-heartbeat-indicator" });
setIcon(hbIcon, "heart-pulse");
hbIcon.title = `Heartbeat: ${agent.heartbeatSchedule}`;
}
titleBlock.createDiv({ cls: "af-agent-card-desc", text: agent.description ?? "No description" });
const toggle = header.createDiv({ cls: `af-agent-card-toggle${agent.enabled ? " on" : ""}` });
toggle.onclick = (e) => {
e.stopPropagation();
void this.plugin.toggleAgent(agent.name, !agent.enabled);
};
// Stats
const stats = card.createDiv({ cls: "af-agent-card-stats" });
const totalRuns = agentRuns.length;
const successRuns = agentRuns.filter((r) => r.status === "success").length;
const successRate = totalRuns > 0 ? Math.round((successRuns / totalRuns) * 100) : 0;
const avgTime =
totalRuns > 0
? Math.round(agentRuns.reduce((s, r) => s + r.durationSeconds, 0) / totalRuns)
: 0;
const totalTokens = agentRuns.reduce((s, r) => s + (r.tokensUsed ?? 0), 0);
this.renderAgentStat(stats, String(totalRuns), "Runs");
this.renderAgentStat(stats, `${successRate}%`, "Success");
this.renderAgentStat(stats, `${avgTime}s`, "Avg Time");
this.renderAgentStat(stats, formatTokenCount(totalTokens), "Tokens");
// Skills
if (agent.skills.length > 0) {
const skillsRow = card.createDiv({ cls: "af-agent-card-skills" });
for (const skill of agent.skills) {
skillsRow.createSpan({ cls: "af-skill-tag", text: skill });
}
}
// Footer
const footer = card.createDiv({ cls: "af-agent-card-footer" });
const metaParts: string[] = [`Model: ${agent.model}`];
if (agent.approvalRequired.length > 0) {
metaParts.push(`Approval: ${agent.approvalRequired.join(", ")}`);
}
if (agent.memory) metaParts.push("Memory: on");
if (!agent.enabled) metaParts.unshift("Disabled");
footer.createSpan({ cls: "af-agent-card-meta", text: metaParts.join(" \u00B7 ") });
const actions = footer.createDiv({ cls: "af-agent-card-actions" });
if (!agent.enabled) {
const enableBtn = actions.createEl("button", { cls: "af-btn-sm", text: "Enable" });
enableBtn.onclick = (e) => {
e.stopPropagation();
void this.plugin.toggleAgent(agent.name, true);
};
}
const editBtn = actions.createEl("button", { cls: "af-btn-sm" });
createIcon(editBtn, "edit", "af-btn-icon");
editBtn.appendText(" Edit");
editBtn.onclick = (e) => {
e.stopPropagation();
this.navigate("edit-agent", agent.name);
};
if (agent.enabled) {
const runBtn = actions.createEl("button", { cls: "af-btn-sm primary" });
createIcon(runBtn, "play", "af-btn-icon");
runBtn.appendText(" Run");
runBtn.onclick = (e) => {
e.stopPropagation();
void this.plugin.runAgentPrompt(agent.name);
};
}
card.onclick = () => this.navigate("agent-detail", agent.name);
}
private renderAgentStat(container: HTMLElement, value: string, label: string): void {
const stat = container.createDiv({ cls: "af-agent-stat" });
stat.createSpan({ cls: "af-agent-stat-value", text: value });
stat.createSpan({ cls: "af-agent-stat-label", text: label });
}
// ═══════════════════════════════════════════════════════
// Agent Detail Page
// ═══════════════════════════════════════════════════════
private renderAgentDetailPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-agent-detail-page" });
const agentName = this.detailContext;
if (!agentName) {
this.renderEmptyState(page, "bot", "No agent selected", "Select an agent from the list");
return;
}
const agent = this.plugin.runtime.getSnapshot().agents.find((a) => a.name === agentName);
if (!agent) {
this.renderEmptyState(page, "bot", "Agent not found", `Agent "${agentName}" was not found`);
return;
}
const state = this.plugin.runtime.getAgentState(agent.name);
const agentRuns = this.plugin.runtime.getRecentRuns().filter((r) => r.agent === agent.name);
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const avatar = headerLeft.createDiv({
cls: `af-agent-card-avatar ${this.healthToClass(state.status)}`,
});
this.renderAgentAvatar(avatar, agent);
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: agent.name });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: agent.description ?? "No description" });
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
// Chat — primary action
const chatBtn = headerActions.createEl("button", { cls: "af-btn-sm primary" });
createIcon(chatBtn, "message-circle", "af-btn-icon");
chatBtn.appendText(" Chat");
chatBtn.onclick = () => this.openChatSlideover(agent);
// Run Now / Enable — secondary
if (agent.enabled) {
const runBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
createIcon(runBtn, "play", "af-btn-icon");
runBtn.appendText(" Run Now");
runBtn.onclick = () => void this.plugin.runAgentPrompt(agent.name);
const pauseBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
createIcon(pauseBtn, "pause", "af-btn-icon");
pauseBtn.appendText(" Disable");
pauseBtn.onclick = () => void this.plugin.toggleAgent(agent.name, false);
} else {
const enableBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
createIcon(enableBtn, "play", "af-btn-icon");
enableBtn.appendText(" Enable");
enableBtn.onclick = () => void this.plugin.toggleAgent(agent.name, true);
}
const detailEditBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
createIcon(detailEditBtn, "edit", "af-btn-icon");
detailEditBtn.appendText(" Edit");
detailEditBtn.onclick = () => this.navigate("edit-agent", agent.name);
const deleteBtn = headerActions.createEl("button", { cls: "af-btn-sm danger" });
createIcon(deleteBtn, "trash-2", "af-btn-icon");
deleteBtn.appendText(" Delete");
deleteBtn.onclick = () => void this.plugin.deleteAgent(agent.name);
// Tabs
const tabs = page.createDiv({ cls: "af-detail-tabs" });
const tabDefs = [
{ id: "overview", label: "Overview", icon: "layout-dashboard" },
{ id: "config", label: "Config", icon: "settings" },
{ id: "runs", label: "Runs", icon: "scroll-text" },
{ id: "memory", label: "Memory", icon: "file-text" },
];
for (const t of tabDefs) {
const tabBtn = tabs.createEl("button", {
cls: `af-detail-tab${this.agentDetailTab === t.id ? " active" : ""}`,
});
createIcon(tabBtn, t.icon, "af-tab-icon");
tabBtn.appendText(` ${t.label}`);
tabBtn.onclick = () => {
this.agentDetailTab = t.id;
void this.render();
};
}
const tabContent = page.createDiv({ cls: "af-detail-tab-content" });
switch (this.agentDetailTab) {
case "overview":
this.renderAgentOverviewTab(tabContent, agent, agentRuns);
break;
case "config":
this.renderAgentConfigTab(tabContent, agent);
break;
case "runs":
this.renderAgentRunsTab(tabContent, agentRuns);
break;
case "memory":
void this.renderAgentMemoryTab(tabContent, agent);
break;
}
}
private renderAgentOverviewTab(container: HTMLElement, agent: AgentConfig, runs: RunLogData[]): void {
// Stats row
const statsRow = container.createDiv({ cls: "af-dash-grid" });
const totalRuns = runs.length;
const successRuns = runs.filter((r) => r.status === "success").length;
const successRate = totalRuns > 0 ? Math.round((successRuns / totalRuns) * 100) : 0;
const avgTime = totalRuns > 0 ? Math.round(runs.reduce((s, r) => s + r.durationSeconds, 0) / totalRuns) : 0;
const totalTokens = runs.reduce((s, r) => s + (r.tokensUsed ?? 0), 0);
const totalCostAgent = runs.reduce((s, r) => s + (r.costUsd ?? 0), 0);
const costSuffixAgent = totalCostAgent > 0 ? ` \u00B7 $${totalCostAgent.toFixed(2)}` : "";
this.renderStatCard(statsRow, "Total Runs", String(totalRuns), "", "activity", "all time");
this.renderStatCard(statsRow, "Success Rate", `${successRate}%`, "", "check-circle-2", `${successRuns}/${totalRuns}`);
this.renderStatCard(statsRow, "Avg Time", `${avgTime}s`, "", "clock", "per run");
this.renderStatCard(statsRow, "Total Tokens", formatTokenCount(totalTokens), "", "zap", `all time${costSuffixAgent}`);
// Heartbeat — compact status only (instruction shown in Config tab)
if (agent.isFolder && (agent.heartbeatBody.trim() || agent.heartbeatEnabled)) {
const hbSection = container.createDiv({ cls: "af-section-card" });
const hbHeader = hbSection.createDiv({ cls: "af-section-header" });
const hbTitle = hbHeader.createDiv({ cls: "af-section-title" });
createIcon(hbTitle, "heart-pulse");
hbTitle.appendText(" Heartbeat");
const hbActions = hbHeader.createDiv({ cls: "af-detail-header-actions" });
const hbToggle = hbActions.createDiv({ cls: `af-agent-card-toggle${agent.heartbeatEnabled ? " on" : ""}` });
hbToggle.onclick = async () => {
const isOn = hbToggle.hasClass("on");
await this.plugin.repository.updateHeartbeat(agent.name, { enabled: !isOn });
await this.plugin.refreshFromVault();
new Notice(`Heartbeat ${!isOn ? "enabled" : "paused"} for ${agent.name}`);
};
const hbBody = hbSection.createDiv({ cls: "af-config-form" });
this.renderConfigRow(hbBody, "Schedule", cronToHuman(agent.heartbeatSchedule));
const nextRun = this.plugin.runtime.getNextHeartbeat(agent.name);
if (nextRun && agent.heartbeatEnabled) {
this.renderConfigRow(hbBody, "Next run", this.timeUntil(nextRun));
}
if (agent.heartbeatChannel) {
this.renderConfigRow(hbBody, "Channel", agent.heartbeatChannel);
}
}
// Skills
if (agent.skills.length > 0) {
const skillsSection = container.createDiv({ cls: "af-section-card" });
const skillsHeader = skillsSection.createDiv({ cls: "af-section-header" });
const skillsTitle = skillsHeader.createDiv({ cls: "af-section-title" });
createIcon(skillsTitle, "puzzle");
skillsTitle.appendText(" Skills");
const skillsBody = skillsSection.createDiv({ cls: "af-detail-skills-list" });
for (const skill of agent.skills) {
skillsBody.createSpan({ cls: "af-skill-tag", text: skill });
}
}
// MCP Servers
const agentMcpServers = agent.mcpServers ?? [];
if (agentMcpServers.length > 0) {
const mcpSection = container.createDiv({ cls: "af-section-card" });
const mcpHeader = mcpSection.createDiv({ cls: "af-section-header" });
const mcpTitle = mcpHeader.createDiv({ cls: "af-section-title" });
createIcon(mcpTitle, "plug");
mcpTitle.appendText(" MCP Servers");
const mcpBody = mcpSection.createDiv({ cls: "af-mcp-overview-list" });
const cachedServers = this.plugin.repository.getMcpServers();
for (const serverName of agentMcpServers) {
const serverInfo = cachedServers.find((s) => s.name === serverName);
const row = mcpBody.createDiv({ cls: "af-mcp-overview-row" });
const dot = row.createSpan({
cls: `af-mcp-status-dot ${serverInfo ? (serverInfo.enabled ? serverInfo.status : "disabled") : "disconnected"}`,
});
dot.title = serverInfo ? (serverInfo.enabled ? serverInfo.status : "disabled") : "unknown";
row.createSpan({ cls: "af-mcp-overview-name", text: serverName });
const toolCount = serverInfo?.toolDetails.length ?? serverInfo?.tools.length ?? 0;
if (toolCount > 0) {
row.createSpan({ cls: "af-mcp-overview-tools", text: `${toolCount} tools` });
} else if (serverInfo && !serverInfo.enabled) {
row.createSpan({ cls: "af-mcp-overview-tools af-muted", text: "disabled" });
} else if (serverInfo?.status === "needs-auth") {
row.createSpan({ cls: "af-mcp-overview-tools af-muted", text: "needs auth" });
}
}
}
// Permissions
const hasPermRules = agent.permissionRules.allow.length > 0 || agent.permissionRules.deny.length > 0;
if (hasPermRules || (agent.permissionMode && agent.permissionMode !== "default")) {
const permSection = container.createDiv({ cls: "af-section-card" });
const permHeader = permSection.createDiv({ cls: "af-section-header" });
const permTitle = permHeader.createDiv({ cls: "af-section-title" });
createIcon(permTitle, "shield-check");
permTitle.appendText(" Permissions");
const permBody = permSection.createDiv({ cls: "af-config-form" });
this.renderConfigRow(permBody, "Mode", agent.permissionMode || "default");
if (agent.permissionRules.allow.length > 0) {
this.renderConfigRow(permBody, "Allowed", agent.permissionRules.allow.join(", "));
}
if (agent.permissionRules.deny.length > 0) {
this.renderConfigRow(permBody, "Denied", agent.permissionRules.deny.join(", "));
}
}
// Recent runs
const runsSection = container.createDiv({ cls: "af-section-card" });
const runsHeader = runsSection.createDiv({ cls: "af-section-header" });
const runsTitle = runsHeader.createDiv({ cls: "af-section-title" });
createIcon(runsTitle, "scroll-text");
runsTitle.appendText(" Recent Runs");
const runsBody = runsSection.createDiv({ cls: "af-timeline" });
if (runs.length === 0) {
this.renderEmptyState(runsBody, "scroll-text", "No runs yet", "");
} else {
for (const run of runs.slice(0, 5)) {
this.renderTimelineItem(runsBody, run);
}
}
}
private renderAgentConfigTab(container: HTMLElement, agent: AgentConfig): void {
const form = container.createDiv({ cls: "af-config-form" });
this.renderConfigRow(form, "Name", agent.name);
this.renderConfigRow(form, "Description", agent.description ?? "");
this.renderConfigRow(form, "Model", agent.model);
this.renderConfigRow(form, "Timeout", `${agent.timeout}s`);
this.renderConfigRow(form, "Working Directory", agent.cwd ?? "(vault root)");
this.renderConfigRow(form, "Permission Mode", agent.permissionMode || "default");
this.renderConfigRow(form, "Approval Required", agent.approvalRequired.join(", ") || "none");
if (agent.permissionRules.allow.length > 0) {
this.renderConfigRow(form, "Allowed Commands", agent.permissionRules.allow.join(", "));
}
if (agent.permissionRules.deny.length > 0) {
this.renderConfigRow(form, "Blocked Commands", agent.permissionRules.deny.join(", "));
}
this.renderConfigRow(form, "Memory", agent.memory ? "Enabled" : "Disabled");
this.renderConfigRow(
form,
"Auto-compact",
agent.autoCompactThreshold && agent.autoCompactThreshold > 0
? `at ${agent.autoCompactThreshold}% context`
: "disabled",
);
if (agent.wikiReferences && agent.wikiReferences.length > 0) {
this.renderConfigRow(
form,
"Wiki access",
agent.wikiReferences.map((r) => r.agent).join(", "),
);
}
this.renderConfigRow(form, "Tags", agent.tags.join(", ") || "none");
const promptSection = form.createDiv({ cls: "af-config-prompt-section" });
promptSection.createDiv({ cls: "af-slideover-section-title", text: "SYSTEM PROMPT" });
promptSection.createDiv({ cls: "af-output-block", text: agent.body || "(empty)" });
if (agent.heartbeatBody.trim()) {
const hbPromptSection = form.createDiv({ cls: "af-config-prompt-section" });
hbPromptSection.createDiv({ cls: "af-slideover-section-title", text: "HEARTBEAT INSTRUCTION" });
hbPromptSection.createDiv({ cls: "af-output-block", text: agent.heartbeatBody });
}
const actions = form.createDiv({ cls: "af-slideover-actions" });
const editBtn = actions.createEl("button", { cls: "af-btn-sm primary" });
createIcon(editBtn, "edit", "af-btn-icon");
editBtn.appendText(" Edit Agent");
editBtn.onclick = () => this.navigate("edit-agent", agent.name);
}
private renderConfigRow(container: HTMLElement, label: string, value: string): void {
const row = container.createDiv({ cls: "af-detail-row" });
row.createSpan({ cls: "af-detail-label", text: label });
row.createSpan({ cls: "af-detail-value af-mono", text: value });
}
private renderAgentRunsTab(container: HTMLElement, runs: RunLogData[]): void {
if (runs.length === 0) {
this.renderEmptyState(container, "scroll-text", "No runs yet", "Run this agent to see history");
return;
}
for (const run of runs) {
const item = container.createDiv({ cls: "af-run-list-item" });
const statusIcon = item.createDiv({ cls: `af-tl-icon ${this.statusToTimelineClass(run.status)}` });
setIcon(statusIcon, this.statusToIconName(run.status));
const body = item.createDiv({ cls: "af-tl-body" });
const titleRow = body.createDiv({ cls: "af-tl-title" });
titleRow.createSpan({ text: run.task });
titleRow.createSpan({ cls: `af-status-badge ${this.statusToBadgeClass(run.status)}`, text: this.statusToBadgeText(run.status) });
const meta = body.createDiv({ cls: "af-tl-meta" });
meta.createSpan({ text: `${this.formatStarted(run.started)} \u00B7 ${this.formatDuration(run.durationSeconds)}` });
if (run.tokensUsed) {
meta.createSpan({ text: `${run.tokensUsed.toLocaleString()} tokens` });
}
body.createDiv({ cls: "af-tl-desc", text: truncate(run.output, 120) });
item.onclick = () => this.openSlideover(run);
}
}
private async renderAgentMemoryTab(container: HTMLElement, agent: AgentConfig): Promise<void> {
if (!agent.memory) {
this.renderEmptyState(container, "file-text", "Memory disabled", "Enable memory in agent config");
return;
}
const wm = await this.plugin.repository.readWorkingMemory(agent.name);
// Header: token usage vs budget + reflection status.
const meta = container.createDiv({ cls: "af-form-help" });
const used = wm?.tokenEstimate ?? 0;
const reflectBits = agent.reflection.enabled
? `reflection on (${agent.reflection.schedule})`
: "reflection off";
meta.setText(`~${used} / ${agent.memoryTokenBudget} tokens · ${reflectBits}`);
if (!wm || wm.sections.length === 0) {
this.renderEmptyState(container, "file-text", "No memories yet", "Agent will learn from runs");
} else {
const block = container.createDiv({ cls: "af-output-block" });
block.setText(renderSections(wm.sections));
}
const actions = container.createDiv({ cls: "af-slideover-actions" });
const reflectBtn = actions.createEl("button", { cls: "af-btn-sm" });
createIcon(reflectBtn, "moon", "af-btn-icon");
reflectBtn.appendText(" Reflect now");
reflectBtn.onclick = async () => {
reflectBtn.disabled = true;
reflectBtn.setText(" Reflecting…");
const res = await this.plugin.runtime.runReflectionNow(agent.name);
new Notice(`Agent Fleet: ${res.message}`);
await this.renderAgentMemoryTab((container.empty(), container), agent);
};
const editBtn = actions.createEl("button", { cls: "af-btn-sm" });
createIcon(editBtn, "external-link", "af-btn-icon");
editBtn.appendText(" Open in Editor");
editBtn.onclick = () =>
void this.plugin.openPath(this.plugin.repository.getWorkingMemoryPath(agent.name));
}
private timeAgo(date: Date): string {
const seconds = Math.round((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return "just now";
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
}
private timeUntil(date: Date): string {
const seconds = Math.round((date.getTime() - Date.now()) / 1000);
if (seconds < 60) return "< 1m";
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `in ${minutes}m`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `in ${hours}h`;
const days = Math.round(hours / 24);
return `in ${days}d`;
}
// ═══════════════════════════════════════════════════════
// Kanban Page
// ═══════════════════════════════════════════════════════
private renderKanbanPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-kanban-page" });
const snapshot = this.plugin.runtime.getSnapshot();
const runs = this.plugin.runtime.getRecentRuns();
const toolbar = page.createDiv({ cls: "af-kanban-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "Tasks Board" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
const newTaskBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" });
createIcon(newTaskBtn, "plus", "af-btn-icon");
newTaskBtn.appendText(" New Task");
newTaskBtn.onclick = () => this.navigate("create-task");
const board = page.createDiv({ cls: "af-kanban-board" });
// Categorize
const backlog: TaskConfig[] = [];
const scheduled: TaskConfig[] = [];
const runningTasks: TaskConfig[] = [];
const completed: RunLogData[] = [];
const failed: RunLogData[] = [];
// Build set of task IDs currently running (not just agent names)
const runningTaskIds = new Set<string>();
for (const agent of snapshot.agents) {
const state = this.plugin.runtime.getAgentState(agent.name);
if (state.status === "running" && state.currentTaskId) {
runningTaskIds.add(state.currentTaskId);
}
}
const today = this.toLocalDateStr(new Date());
// Collect today's runs first (needed to filter tasks)
for (const run of runs) {
if (run.status === "success" && this.runToLocalDate(run.started) === today) {
completed.push(run);
} else if (
(run.status === "failure" || run.status === "timeout" || run.status === "cancelled") &&
this.runToLocalDate(run.started) === today
) {
failed.push(run);
}
}
// Tasks that completed or failed today (by task ID)
const completedTaskIds = new Set(completed.map((r) => r.task));
const failedTaskIds = new Set(failed.map((r) => r.task));
for (const task of snapshot.tasks) {
const ranToday = completedTaskIds.has(task.taskId) || failedTaskIds.has(task.taskId)
|| (task.lastRun && this.runToLocalDate(task.lastRun) === today);
if (runningTaskIds.has(task.taskId)) {
runningTasks.push(task);
} else if (ranToday && !task.schedule) {
// One-shot task ran today — skip from backlog, shown via run cards in Done/Failed
continue;
} else if (task.schedule && task.enabled) {
scheduled.push(task);
} else {
backlog.push(task);
}
}
// Columns with drag-and-drop
this.renderKanbanColumn(board, "Backlog", "inbox", backlog.length, (body) => {
for (const task of backlog) {
this.renderKanbanTaskCard(body, task, snapshot, true);
}
}, "backlog");
this.renderKanbanColumn(board, "Scheduled", "clock", scheduled.length, (body) => {
for (const task of scheduled) {
this.renderKanbanTaskCard(body, task, snapshot, true);
}
}, "scheduled");
this.renderKanbanColumn(board, "Running", "loader-2", runningTasks.length, (body) => {
for (const task of runningTasks) {
this.renderKanbanRunningCard(body, task);
}
}, "running", false, "running");
this.renderKanbanColumn(board, "Done", "check-circle-2", completed.length, (body) => {
for (const run of completed.slice(0, 10)) {
this.renderKanbanCompletedCard(body, run);
}
}, "completed");
this.renderKanbanColumn(board, "Failed", "x-circle", failed.length, (body) => {
for (const run of failed) {
this.renderKanbanFailedCard(body, run);
}
}, "failed", false, "failed");
}
private renderKanbanColumn(
container: HTMLElement,
title: string,
iconName: string,
count: number,
renderBody: (body: HTMLElement) => void,
columnId: string,
showAddBtn = false,
variant?: string,
): void {
const col = container.createDiv({
cls: `af-kanban-column${variant ? ` af-kanban-${variant}` : ""}`,
});
// Set up drop target for backlog and scheduled columns
if (columnId === "backlog" || columnId === "scheduled") {
makeDropTarget(col, (taskId) => this.handleTaskDrop(taskId, columnId));
}
const header = col.createDiv({ cls: "af-kanban-col-header" });
const titleEl = header.createDiv({ cls: "af-kanban-col-title" });
createIcon(titleEl, iconName);
titleEl.appendText(` ${title} `);
titleEl.createSpan({ cls: "af-kanban-col-count", text: String(count) });
const body = col.createDiv({ cls: "af-kanban-col-body" });
renderBody(body);
if (count === 0) {
body.createDiv({ cls: "af-kanban-empty", text: "No items" });
}
if (showAddBtn) {
const addSection = col.createDiv({ cls: "af-kanban-col-add" });
const addBtn = addSection.createEl("button");
createIcon(addBtn, "plus", "af-btn-icon");
addBtn.appendText(" Add task");
addBtn.onclick = () => { this.navigate("create-task"); };
}
}
private handleTaskDrop(taskId: string, columnId: string): void {
const task = this.plugin.runtime.getSnapshot().tasks.find((t) => t.taskId === taskId);
if (!task) return;
if (columnId === "backlog") {
void this.setTaskEnabled(task, false).then(() => {
new Notice(`Task "${taskId}" moved to backlog (disabled)`);
});
} else if (columnId === "scheduled") {
if (!task.schedule && !task.runAt) {
new Notice(`Task "${taskId}" needs a schedule. Open task details to set one.`);
this.navigate("task-detail", taskId);
return;
}
void this.setTaskEnabled(task, true).then(() => {
new Notice(`Task "${taskId}" moved to scheduled (enabled)`);
});
}
}
private async setTaskEnabled(task: TaskConfig, enabled: boolean): Promise<void> {
const file = this.plugin.app.vault.getAbstractFileByPath(task.filePath);
if (!file || !(file instanceof TFile)) return;
const content = await this.plugin.app.vault.cachedRead(file);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
frontmatter.enabled = enabled;
await this.plugin.app.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, body));
await this.plugin.refreshFromVault();
}
private renderKanbanTaskCard(
container: HTMLElement,
task: TaskConfig,
snapshot: { agents: AgentConfig[] },
draggable: boolean,
): void {
const card = container.createDiv({ cls: `af-kanban-card af-priority-${task.priority}` });
if (draggable) {
makeDraggable(card, task.taskId);
const gripEl = card.createDiv({ cls: "af-kanban-card-grip" });
setIcon(gripEl, "grip-vertical");
}
// Title row with task name + active indicator
const titleRow = card.createDiv({ cls: "af-kanban-card-header" });
titleRow.createDiv({ cls: "af-kanban-card-title", text: task.taskId });
const agent = snapshot.agents.find((a) => a.name === task.agent);
const isActive = (agent?.enabled ?? false) && task.enabled;
const statusDot = titleRow.createSpan({
cls: `af-kanban-card-status ${isActive ? "active" : "inactive"}`,
});
statusDot.title = isActive ? "Active" : "Inactive";
// Agent row with icon + name
const agentRow = card.createDiv({ cls: "af-kanban-card-agent" });
const agentIconEl = agentRow.createSpan({ cls: "af-kanban-card-agent-icon" });
setIcon(agentIconEl, "bot");
agentRow.createSpan({ cls: "af-kanban-card-agent-name", text: task.agent });
// Footer with schedule info
const footer = card.createDiv({ cls: "af-kanban-card-footer" });
const scheduleEl = footer.createSpan({ cls: "af-kanban-card-schedule" });
if (!isActive) {
createIcon(scheduleEl, "pause", "af-meta-icon");
scheduleEl.appendText(" Paused");
} else if (task.schedule) {
createIcon(scheduleEl, "refresh-cw", "af-meta-icon");
scheduleEl.appendText(` ${this.humanizeCron(task.schedule)}`);
} else {
scheduleEl.appendText(task.runAt ?? "Manual");
}
card.onclick = () => this.navigate("task-detail", task.taskId);
}
private renderKanbanRunningCard(container: HTMLElement, task: TaskConfig): void {
const card = container.createDiv({ cls: "af-kanban-card af-kanban-card-running" });
card.createDiv({ cls: "af-kanban-card-title", text: task.taskId });
const agentRow = card.createDiv({ cls: "af-kanban-card-agent" });
const agentIconEl = agentRow.createSpan({ cls: "af-kanban-card-agent-icon" });
setIcon(agentIconEl, "bot");
agentRow.createSpan({ cls: "af-kanban-card-agent-name", text: task.agent });
// Progress bar tied to agent timeout
const agent = this.plugin.runtime.getSnapshot().agents.find((a) => a.name === task.agent);
const timeout = agent?.timeout ?? 300;
const state = this.plugin.runtime.getAgentState(task.agent);
const startTime = state.runStarted ? new Date(state.runStarted).getTime() : Date.now();
const progress = card.createDiv({ cls: "af-kanban-progress" });
const track = progress.createDiv({ cls: "af-kanban-progress-track" });
const bar = track.createDiv({ cls: "af-kanban-progress-bar af-kanban-progress-bar-real" });
const elapsed = (Date.now() - startTime) / 1000;
const pct = Math.min(95, (elapsed / timeout) * 100); // Cap at 95% until actually done
bar.setCssStyles({ width: `${pct}%` });
const footer = card.createDiv({ cls: "af-kanban-card-footer" });
const scheduleEl = footer.createSpan({ cls: "af-kanban-card-schedule" });
createIcon(scheduleEl, "loader-2", "af-meta-icon");
const elapsedSec = Math.round(elapsed);
scheduleEl.appendText(` ${elapsedSec}s / ${timeout}s`);
// Stop button
const stopBtn = footer.createEl("button", { cls: "af-kanban-stop-btn" });
setIcon(stopBtn, "square");
stopBtn.title = "Stop task";
stopBtn.onclick = (e) => {
e.stopPropagation();
this.plugin.runtime.abortAgentRun(task.agent);
new Notice(`Stopped task "${task.taskId}"`);
};
// Live update every second
const interval = window.setInterval(() => {
const now = (Date.now() - startTime) / 1000;
const newPct = Math.min(95, (now / timeout) * 100);
bar.setCssStyles({ width: `${newPct}%` });
const nowSec = Math.round(now);
scheduleEl.textContent = "";
setIcon(scheduleEl, "loader-2");
scheduleEl.appendText(` ${nowSec}s / ${timeout}s`);
}, 1000);
this.streamingUnsubscribes.push(() => window.clearInterval(interval));
}
private renderKanbanCompletedCard(container: HTMLElement, run: RunLogData): void {
const card = container.createDiv({ cls: "af-kanban-card" });
card.createDiv({ cls: "af-kanban-card-title", text: run.task });
const agentRow = card.createDiv({ cls: "af-kanban-card-agent" });
const agentIconEl = agentRow.createSpan({ cls: "af-kanban-card-agent-icon" });
setIcon(agentIconEl, "bot");
agentRow.createSpan({ cls: "af-kanban-card-agent-name", text: run.agent });
const footer = card.createDiv({ cls: "af-kanban-card-footer" });
const scheduleEl = footer.createSpan({ cls: "af-kanban-card-schedule" });
createIcon(scheduleEl, "check-circle-2", "af-meta-icon");
scheduleEl.appendText(
` ${this.formatStarted(run.started)} \u00B7 ${this.formatDuration(run.durationSeconds)}`,
);
card.onclick = () => this.openSlideover(run);
}
private renderKanbanFailedCard(container: HTMLElement, run: RunLogData): void {
const isCancelled = run.status === "cancelled";
const card = container.createDiv({ cls: `af-kanban-card ${isCancelled ? "af-kanban-card-cancelled" : "af-kanban-card-failed"}` });
card.createDiv({ cls: "af-kanban-card-title", text: run.task });
const agentRow = card.createDiv({ cls: "af-kanban-card-agent" });
const agentIconEl = agentRow.createSpan({ cls: "af-kanban-card-agent-icon" });
setIcon(agentIconEl, "bot");
agentRow.createSpan({ cls: "af-kanban-card-agent-name", text: run.agent });
const errorText = isCancelled
? `Stopped after ${run.durationSeconds}s`
: run.status === "timeout"
? `Timeout after ${run.durationSeconds}s`
: truncate(run.output, 60);
const errorDiv = card.createDiv({ cls: "af-kanban-card-error" });
createIcon(errorDiv, isCancelled ? "square" : "alert-triangle", "af-meta-icon");
errorDiv.appendText(` ${errorText}`);
const footer = card.createDiv({ cls: "af-kanban-card-footer" });
const scheduleEl = footer.createSpan({ cls: "af-kanban-card-schedule" });
createIcon(scheduleEl, isCancelled ? "square" : "x-circle", "af-meta-icon");
scheduleEl.appendText(` ${this.formatStarted(run.started)}`);
if (!isCancelled) {
const retryBtn = footer.createEl("button", { cls: "af-btn-sm" });
createIcon(retryBtn, "refresh-cw", "af-btn-icon");
retryBtn.appendText(" Retry");
retryBtn.onclick = (e) => {
e.stopPropagation();
void this.plugin.runAgentPrompt(run.agent);
};
}
card.onclick = () => this.openSlideover(run);
}
// ═══════════════════════════════════════════════════════
// Runs Page
// ═══════════════════════════════════════════════════════
private renderRunsPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-runs-page" });
const runs = this.plugin.runtime.getRecentRuns();
const toolbar = page.createDiv({ cls: "af-runs-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "Run History" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
const tableWrap = page.createDiv({ cls: "af-runs-table" });
if (runs.length === 0) {
this.renderEmptyState(tableWrap, "scroll-text", "No runs yet", "Run an agent to see history here");
return;
}
const table = tableWrap.createEl("table");
const thead = table.createEl("thead");
const headerRow = thead.createEl("tr");
for (const col of ["Status", "Agent", "Task", "Started", "Duration", "Tokens", "Model"]) {
headerRow.createEl("th", { text: col });
}
const tbody = table.createEl("tbody");
for (const run of runs.slice(0, 50)) {
this.renderRunRow(tbody, run);
}
}
private renderRunRow(tbody: HTMLElement, run: RunLogData): void {
const row = tbody.createEl("tr");
const statusTd = row.createEl("td");
const badge = statusTd.createSpan({
cls: `af-status-badge ${this.statusToBadgeClass(run.status)}`,
});
const badgeIcon = badge.createSpan();
setIcon(badgeIcon, this.statusToIconName(run.status));
badge.appendText(` ${this.statusToBadgeText(run.status)}`);
const agentTd = row.createEl("td", { cls: "af-agent-link" });
agentTd.setText(run.agent);
agentTd.onclick = (e) => {
e.stopPropagation();
this.navigate("agent-detail", run.agent);
};
row.createEl("td", { text: run.task });
row.createEl("td", { cls: "af-mono", text: this.formatStarted(run.started) });
row.createEl("td", { cls: "af-mono", text: this.formatDuration(run.durationSeconds) });
row.createEl("td", {
cls: "af-mono",
text: run.tokensUsed ? run.tokensUsed.toLocaleString() : "\u2014",
});
row.createEl("td", { cls: "af-mono", text: run.model });
row.setCssStyles({ cursor: "pointer" });
row.onclick = () => this.openSlideover(run);
}
// ═══════════════════════════════════════════════════════
// Skills Page
// ═══════════════════════════════════════════════════════
private renderSkillsPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-skills-page" });
const snapshot = this.plugin.runtime.getSnapshot();
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "Skills Library" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" });
createIcon(newBtn, "plus", "af-btn-icon");
newBtn.appendText(" New Skill");
newBtn.onclick = () => void this.plugin.createSkillTemplate();
const grid = page.createDiv({ cls: "af-skills-grid" });
if (snapshot.skills.length === 0) {
this.renderEmptyState(grid, "puzzle", "No skills yet", "Create skills to give agents specialized abilities");
return;
}
for (const skill of snapshot.skills) {
this.renderSkillCard(grid, skill, snapshot.agents);
}
}
private renderSkillCard(container: HTMLElement, skill: SkillConfig, agents: AgentConfig[]): void {
const card = container.createDiv({ cls: "af-skill-card" });
const cardHeader = card.createDiv({ cls: "af-skill-card-header" });
const iconEl = cardHeader.createDiv({ cls: "af-skill-card-icon" });
setIcon(iconEl, this.getSkillIcon(skill.name));
const skillEditBtn = cardHeader.createEl("button", { cls: "af-btn-sm af-btn-xs" });
createIcon(skillEditBtn, "edit", "af-btn-icon");
skillEditBtn.onclick = (e) => {
e.stopPropagation();
this.navigate("edit-skill", skill.name);
};
card.createDiv({ cls: "af-skill-card-name", text: skill.name });
card.createDiv({ cls: "af-skill-card-desc", text: skill.description ?? "No description" });
const usedBy = agents.filter((a) => a.skills.includes(skill.name));
if (usedBy.length > 0) {
const agentsRow = card.createDiv({ cls: "af-skill-card-agents" });
for (const agent of usedBy) {
agentsRow.createSpan({ cls: "af-skill-card-agent-tag", text: agent.name });
}
}
// No card-level click — skills have no detail page. Editing is via the edit button.
}
// ═══════════════════════════════════════════════════════
// Channels Page
// ═══════════════════════════════════════════════════════
private renderChannelsPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-agents-page" });
const snapshot = this.plugin.runtime.getSnapshot();
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "Channels" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" });
createIcon(newBtn, "plus", "af-btn-icon");
newBtn.appendText(" New Channel");
newBtn.onclick = () => this.navigate("create-channel");
const grid = page.createDiv({ cls: "af-agents-grid" });
if (snapshot.channels.length === 0) {
this.renderEmptyState(
grid,
"radio",
"No channels configured",
"Connect an agent to Slack or another chat platform",
);
return;
}
for (const channel of snapshot.channels) {
this.renderChannelCard(grid, channel, snapshot.validationIssues);
}
}
/**
* Wiki Keepers page: lists every Wiki Keeper instance with the latest
* lint report parsed from its scope's `log.md`. "Needs review" items
* render as cards with a Dismiss button (in-memory only — re-parse on
* navigation gives the user a fresh view of the most recent lint pass).
*/
private async renderWikiKeepersPage(container: HTMLElement): Promise<void> {
const page = container.createDiv({ cls: "af-agents-page" });
const snapshot = this.plugin.runtime.getSnapshot();
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "Wiki Keepers" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
const keepers = snapshot.agents.filter(
(a): a is AgentConfig & { wikiKeeper: NonNullable<AgentConfig["wikiKeeper"]> } =>
a.wikiKeeper !== undefined,
);
if (keepers.length === 0) {
this.renderEmptyState(
page,
"library",
"No Wiki Keepers yet",
"Open Settings → Agent Fleet → Wiki Keepers → + Add to create one.",
);
return;
}
const list = page.createDiv({ cls: "af-wk-list" });
list.setCssStyles({ display: "flex" });
list.setCssStyles({ flexDirection: "column" });
list.setCssStyles({ gap: "16px" });
for (const keeper of keepers) {
await this.renderWikiKeeperCard(list, keeper);
}
}
private async renderWikiKeeperCard(
container: HTMLElement,
agent: AgentConfig & { wikiKeeper: NonNullable<AgentConfig["wikiKeeper"]> },
): Promise<void> {
const wk = agent.wikiKeeper;
const card = container.createDiv({ cls: "af-card" });
card.setCssStyles({ padding: "16px" });
card.setCssStyles({ border: "1px solid var(--background-modifier-border)" });
card.setCssStyles({ borderRadius: "8px" });
const header = card.createDiv();
header.setCssStyles({ display: "flex" });
header.setCssStyles({ alignItems: "center" });
header.setCssStyles({ gap: "12px" });
header.setCssStyles({ marginBottom: "12px" });
const titleWrap = header.createDiv();
titleWrap.setCssStyles({ flex: "1" });
titleWrap.createEl("strong", { text: agent.name });
const scopeLabel = wk.scopeRoot || "(whole vault)";
titleWrap.createEl("div", {
text: `Scope: ${scopeLabel} · topics: ${wk.topicsRoot}/ · log: ${wk.logPath}`,
cls: "af-form-hint",
});
const openBtn = header.createEl("button", { cls: "af-btn-sm" });
openBtn.appendText("Open log");
openBtn.onclick = () => {
const logFullPath = wk.scopeRoot ? `${wk.scopeRoot}/${wk.logPath}` : wk.logPath;
const file = this.plugin.app.vault.getAbstractFileByPath(logFullPath);
if (file instanceof TFile) {
void this.plugin.app.workspace.getLeaf().openFile(file);
} else {
new Notice(`Log file not found: ${logFullPath}`);
}
};
// Read and parse log.md
const logFullPath = wk.scopeRoot ? `${wk.scopeRoot}/${wk.logPath}` : wk.logPath;
const logFile = this.plugin.app.vault.getAbstractFileByPath(logFullPath);
let report: ReturnType<typeof parseLatestLintReport> = null;
if (logFile instanceof TFile) {
try {
const content = await this.plugin.app.vault.cachedRead(logFile);
report = parseLatestLintReport(content);
} catch {
report = null;
}
}
if (!report) {
const empty = card.createDiv({ cls: "af-form-hint" });
empty.setText(
"No lint report yet. Run wiki-lint manually or wait for the weekly task to fire.",
);
return;
}
// Latest lint header
const reportHeader = card.createDiv();
reportHeader.setCssStyles({ display: "flex" });
reportHeader.setCssStyles({ alignItems: "baseline" });
reportHeader.setCssStyles({ gap: "12px" });
reportHeader.setCssStyles({ marginBottom: "8px" });
reportHeader.createEl("strong", { text: `Lint ${report.date}` });
reportHeader.createSpan({
cls: "af-form-hint",
text: `${report.summary.length} summary lines · ${report.autoApplied.length} auto-applied · ${report.needsReview.length} needs review`,
});
// Summary bullets (read-only)
if (report.summary.length > 0) {
const sumDetails = card.createEl("details");
sumDetails.createEl("summary", { text: "Summary" });
const sumList = sumDetails.createEl("ul");
sumList.setCssStyles({ marginTop: "4px" });
for (const item of report.summary) {
sumList.createEl("li", { text: item });
}
}
if (report.autoApplied.length > 0) {
const autoDetails = card.createEl("details");
autoDetails.createEl("summary", { text: `Auto-applied (${report.autoApplied.length})` });
const autoList = autoDetails.createEl("ul");
autoList.setCssStyles({ marginTop: "4px" });
for (const item of report.autoApplied) {
autoList.createEl("li", { text: item });
}
}
if (report.refreshChained.length > 0) {
const refDetails = card.createEl("details");
refDetails.createEl("summary", {
text: `Refresh chained (${report.refreshChained.length})`,
});
const refList = refDetails.createEl("ul");
refList.setCssStyles({ marginTop: "4px" });
for (const item of report.refreshChained) {
refList.createEl("li", { text: item });
}
}
// Needs review queue — the actionable bit
const reviewWrap = card.createDiv();
reviewWrap.setCssStyles({ marginTop: "12px" });
reviewWrap.createEl("strong", { text: `Needs review (${report.needsReview.length})` });
if (report.needsReview.length === 0) {
reviewWrap.createDiv({
cls: "af-form-hint",
text: "All clear. Nothing requires manual review from this lint pass.",
});
return;
}
const reviewList = reviewWrap.createDiv();
reviewList.setCssStyles({ display: "flex" });
reviewList.setCssStyles({ flexDirection: "column" });
reviewList.setCssStyles({ gap: "6px" });
reviewList.setCssStyles({ marginTop: "8px" });
for (const item of report.needsReview) {
const row = reviewList.createDiv();
row.setCssStyles({ display: "flex" });
row.setCssStyles({ alignItems: "flex-start" });
row.setCssStyles({ gap: "8px" });
row.setCssStyles({ padding: "8px 10px" });
row.setCssStyles({ background: "var(--background-secondary)" });
row.setCssStyles({ borderRadius: "4px" });
row.setCssStyles({ fontSize: "13px" });
const text = row.createDiv();
text.setCssStyles({ flex: "1" });
text.setText(item);
const dismissBtn = row.createEl("button", { cls: "af-btn-sm", text: "Dismiss" });
dismissBtn.title =
"Hide this item from the dashboard until the next lint pass (does not modify log.md).";
dismissBtn.onclick = () => {
row.remove();
};
}
}
private renderChannelCard(
container: HTMLElement,
channel: ChannelConfig,
validationIssues: Array<{ path: string; message: string }>,
): void {
const status = this.plugin.channelManager?.getChannelStatus(channel.name) ?? "disabled";
const avatarCls = channelStatusToAvatarClass(status);
const cardCls = channel.enabled && status !== "disabled" ? "af-agent-card" : "af-agent-card disabled";
const card = container.createDiv({ cls: cardCls });
card.setCssStyles({ cursor: "default" }); // No card-level click — channels have no detail page
// Header — avatar (icon + status color) + name/agent + type pill
const header = card.createDiv({ cls: "af-agent-card-header" });
const avatar = header.createDiv({ cls: `af-agent-card-avatar ${avatarCls}` });
setIcon(avatar, "radio");
const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" });
titleBlock.createDiv({ cls: "af-agent-card-name", text: channel.name });
titleBlock.createDiv({
cls: "af-agent-card-desc",
text: `Default: ${channel.defaultAgent}`,
});
const statusPill = header.createSpan({ cls: `af-pill ${channelStatusPillColor(status)}` });
statusPill.createSpan({ cls: "af-dot" });
statusPill.appendText(` ${status}`);
// Agents — show all allowed agents as tags (same style as skill tags on agent cards)
if (channel.allowedAgents.length > 0) {
const agentsRow = card.createDiv({ cls: "af-agent-card-skills" });
for (const name of channel.allowedAgents) {
const tag = agentsRow.createSpan({ cls: "af-skill-tag", text: name });
if (name === channel.defaultAgent) {
tag.setCssStyles({ fontWeight: "700" });
}
}
}
// Stats — sessions, messages in/out, allowlist
const stats = card.createDiv({ cls: "af-agent-card-stats" });
const sessionCount = this.plugin.channelManager?.getSessionCount(channel.name) ?? 0;
const metrics = this.plugin.channelManager?.getMetrics(channel.name);
const agentCount = channel.allowedAgents.length > 0
? String(channel.allowedAgents.length)
: "all";
this.renderAgentStat(stats, agentCount, "Agents");
this.renderAgentStat(stats, String(sessionCount), "Sessions");
this.renderAgentStat(stats, String(metrics?.messagesReceived ?? 0), "In");
this.renderAgentStat(stats, String(metrics?.messagesSent ?? 0), "Out");
// Footer — meta text + edit action
const footer = card.createDiv({ cls: "af-agent-card-footer" });
const metaParts: string[] = [channel.type];
if (!channel.enabled) metaParts.push("disabled");
if (channel.allowedUsers.length > 0) metaParts.push(`${channel.allowedUsers.length} user(s)`);
else metaParts.push("allowlist empty");
footer.createSpan({ cls: "af-agent-card-meta", text: metaParts.join(" \u00B7 ") });
const actions = footer.createDiv({ cls: "af-agent-card-actions" });
const editBtn = actions.createEl("button", { cls: "af-btn-sm" });
createIcon(editBtn, "edit", "af-btn-icon");
editBtn.appendText(" Edit");
editBtn.onclick = (e) => {
e.stopPropagation();
this.navigate("edit-channel", channel.name);
};
// Validation issues — red-tinted block below the footer
const issues = validationIssues.filter((i) => i.path === channel.filePath);
if (issues.length > 0) {
const issuesBox = card.createDiv({ cls: "af-channel-issues" });
for (const issue of issues) {
issuesBox.createDiv({ cls: "af-channel-issue-row", text: issue.message });
}
}
// No card-level click — channels have no detail page. Editing is via the edit button.
}
// ═══════════════════════════════════════════════════════
// Create Channel Page
// ═══════════════════════════════════════════════════════
private renderCreateChannelPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-create-agent-page" });
const snapshot = this.plugin.runtime.getSnapshot();
const credentials = this.plugin.channelCredentials.list();
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(avatar, "plus");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Channel" });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Connect an external chat transport to an agent" });
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
// Form state
const state = {
name: "",
type: "slack",
defaultAgent: snapshot.agents[0]?.name ?? "",
allowedAgents: [] as string[],
credentialRef: credentials[0]?.ref ?? "",
allowedUsers: "",
perUserSessions: true,
channelContext: "",
enabled: true,
tags: "",
body: "",
transportJson: "",
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Channel Details ───
const detailsSection = form.createDiv({ cls: "af-create-section" });
const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" });
const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(detailsIcon, "radio");
detailsHeader.createSpan({ text: "Channel Details" });
this.createFormField(detailsSection, "Name", "my-slack", "Unique identifier for this channel", (v) => { state.name = v; });
// Type dropdown — only Slack is supported in v1; others shown as disabled
const typeRow = detailsSection.createDiv({ cls: "af-form-row" });
typeRow.createDiv({ cls: "af-form-label", text: "Type" });
const typeSelect = typeRow.createEl("select", { cls: "af-form-select" });
typeSelect.createEl("option", { text: "slack", attr: { value: "slack" } });
typeSelect.createEl("option", { text: "telegram", attr: { value: "telegram" } });
typeSelect.addEventListener("change", () => { state.type = typeSelect.value as ChannelConfig["type"]; });
// Credential dropdown
const credRow = detailsSection.createDiv({ cls: "af-form-row" });
const credLabel = credRow.createDiv({ cls: "af-form-label" });
credLabel.setText("Credential");
this.addTooltip(credLabel, "Configured in Settings → Agent Fleet → Channel Credentials");
const credSelect = credRow.createEl("select", { cls: "af-form-select" });
if (credentials.length === 0) {
credSelect.createEl("option", { text: "(no credentials configured)", attr: { value: "" } });
}
for (const c of credentials) {
credSelect.createEl("option", { text: `${c.ref} (${c.entry.type})`, attr: { value: c.ref } });
}
credSelect.addEventListener("change", () => { state.credentialRef = credSelect.value; });
// Enabled toggle
const enabledRow = detailsSection.createDiv({ cls: "af-form-row af-form-row-toggle" });
enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
const enabledToggle = enabledRow.createDiv({ cls: "af-agent-card-toggle on" });
enabledToggle.onclick = () => {
const isOn = enabledToggle.hasClass("on");
enabledToggle.toggleClass("on", !isOn);
state.enabled = !isOn;
};
// ─── Agent Routing ───
const agentSection = form.createDiv({ cls: "af-create-section" });
const agentHeader = agentSection.createDiv({ cls: "af-create-section-header" });
const agentIcon = agentHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(agentIcon, "bot");
agentHeader.createSpan({ text: "Agent Routing" });
// Default agent dropdown
const defaultRow = agentSection.createDiv({ cls: "af-form-row" });
const defaultLabel = defaultRow.createDiv({ cls: "af-form-label" });
defaultLabel.setText("Default agent");
this.addTooltip(defaultLabel, "Used when no @agent-name prefix is given in a message");
const defaultSelect = defaultRow.createEl("select", { cls: "af-form-select" });
for (const a of snapshot.agents) {
defaultSelect.createEl("option", { text: a.name, attr: { value: a.name } });
}
defaultSelect.addEventListener("change", () => { state.defaultAgent = defaultSelect.value; });
// Allowed agents checkboxes
const allowedRow = agentSection.createDiv({ cls: "af-form-row" });
const allowedLabel = allowedRow.createDiv({ cls: "af-form-label" });
allowedLabel.setText("Allowed agents");
this.addTooltip(allowedLabel, "Agents reachable via @prefix. Leave unchecked to allow all agents.");
const checkboxContainer = allowedRow.createDiv({ cls: "af-form-checkboxes" });
for (const a of snapshot.agents) {
const label = checkboxContainer.createEl("label", { cls: "af-form-checkbox-label" });
const cb = label.createEl("input", { attr: { type: "checkbox", value: a.name } });
label.appendText(` ${a.name}`);
cb.addEventListener("change", () => {
if (cb.checked) {
if (!state.allowedAgents.includes(a.name)) state.allowedAgents.push(a.name);
} else {
state.allowedAgents = state.allowedAgents.filter((n) => n !== a.name);
}
});
}
// Per-user sessions toggle
const perUserRow = agentSection.createDiv({ cls: "af-form-row af-form-row-toggle" });
const perUserLabel = perUserRow.createDiv({ cls: "af-form-label" });
perUserLabel.setText("Per-user sessions");
this.addTooltip(perUserLabel, "Each external user gets their own isolated Claude session");
const perUserToggle = perUserRow.createDiv({ cls: "af-agent-card-toggle on" });
perUserToggle.onclick = () => {
const isOn = perUserToggle.hasClass("on");
perUserToggle.toggleClass("on", !isOn);
state.perUserSessions = !isOn;
};
// ─── Access Control ───
const accessSection = form.createDiv({ cls: "af-create-section" });
const accessHeader = accessSection.createDiv({ cls: "af-create-section-header" });
const accessIcon = accessHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(accessIcon, "shield-check");
accessHeader.createSpan({ text: "Access Control" });
const usersLabel = accessSection.createDiv({ cls: "af-form-label" });
usersLabel.setText("Allowed users");
this.addTooltip(usersLabel, "Slack user IDs (U...), one per line. Only listed users can reach the bot.");
const usersTextarea = accessSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "U0AQW6P37N1\nU0BXYZ12345", rows: "4" },
});
usersTextarea.addEventListener("input", () => { state.allowedUsers = usersTextarea.value; });
// ─── Channel Context ───
const contextSection = form.createDiv({ cls: "af-create-section" });
const contextHeader = contextSection.createDiv({ cls: "af-create-section-header" });
const contextIcon = contextHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(contextIcon, "message-square");
const contextHeaderLabel = contextHeader.createSpan({ text: "Channel Context" });
this.addTooltip(contextHeaderLabel, "Extra instructions appended to the agent's system prompt when reached through this channel");
const contextTextarea = contextSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "You are being contacted via Slack. Keep replies concise...", rows: "6" },
});
contextTextarea.addEventListener("input", () => { state.channelContext = contextTextarea.value; });
this.createFormField(detailsSection, "Tags", "ops, internal", "Comma-separated metadata", (v) => { state.tags = v; });
// ─── Advanced ───
const advSection = form.createDiv({ cls: "af-create-section" });
const advHeader = advSection.createDiv({ cls: "af-create-section-header" });
const advIcon = advHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(advIcon, "settings");
const advHeaderLabel = advHeader.createSpan({ text: "Advanced" });
this.addTooltip(advHeaderLabel, "Markdown body (shown in the channel detail page) and transport-specific overrides");
const bodyLabel = advSection.createDiv({ cls: "af-form-label" });
bodyLabel.setText("Body (markdown)");
this.addTooltip(bodyLabel, "Free-form notes for this channel. Shown in the channel detail page; not sent to the agent.");
const bodyTextarea = advSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "Notes, runbook snippets, escalation contacts…", rows: "4" },
});
bodyTextarea.addEventListener("input", () => { state.body = bodyTextarea.value; });
const transportLabel = advSection.createDiv({ cls: "af-form-label" });
transportLabel.setText("Transport config (JSON)");
this.addTooltip(transportLabel, "Optional JSON object for transport-specific overrides (e.g. Slack socket_mode, telegram webhook settings). Leave blank for defaults.");
const transportTextarea = advSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: '{\n "socket_mode": true\n}', rows: "4" },
});
transportTextarea.addEventListener("input", () => { state.transportJson = transportTextarea.value; });
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => this.navigate("channels");
const createBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(createBtn, "plus", "af-btn-icon");
createBtn.appendText(" Create Channel");
createBtn.onclick = async () => {
const name = state.name.trim();
if (!name) { new Notice("Channel name is required."); return; }
if (!state.credentialRef) { new Notice("Select a credential."); return; }
let transport: Record<string, unknown> | undefined;
if (state.transportJson.trim()) {
try {
const parsed = JSON.parse(state.transportJson);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
transport = parsed as Record<string, unknown>;
} else {
new Notice("Transport config must be a JSON object.");
return;
}
} catch (err) {
new Notice(`Transport JSON is invalid: ${err instanceof Error ? err.message : String(err)}`);
return;
}
}
const parseUsers = (s: string) => s.split(/[\n,]+/).map((t) => t.trim()).filter(Boolean);
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
const frontmatter: Record<string, unknown> = {
name: slugify(name),
type: state.type,
default_agent: state.defaultAgent,
allowed_agents: state.allowedAgents.length > 0 ? state.allowedAgents : undefined,
enabled: state.enabled,
credential_ref: state.credentialRef,
allowed_users: parseUsers(state.allowedUsers),
per_user_sessions: state.perUserSessions,
channel_context: state.channelContext.trim() || undefined,
tags: parseTags(state.tags).length > 0 ? parseTags(state.tags) : undefined,
transport,
};
try {
const channelSlug = slugify(name);
const path = await this.plugin.repository.getAvailablePath(
this.plugin.repository.getSubfolder("channels"),
channelSlug,
);
await this.plugin.app.vault.create(
path,
stringifyMarkdownWithFrontmatter(frontmatter, state.body.trim()),
);
new Notice(`Channel "${channelSlug}" created.`);
await this.plugin.refreshFromVault();
this.navigate("edit-channel", channelSlug);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to create channel: ${msg}`);
}
};
}
// ═══════════════════════════════════════════════════════
// Edit Channel Page
// ═══════════════════════════════════════════════════════
private renderEditChannelPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-create-agent-page" });
const channelName = this.detailContext;
if (!channelName) {
this.renderEmptyState(page, "radio", "No channel selected", "");
return;
}
const channel = this.plugin.runtime.getSnapshot().channels.find((c) => c.name === channelName);
if (!channel) {
this.renderEmptyState(page, "radio", "Channel not found", `Channel "${channelName}" was not found`);
return;
}
const snapshot = this.plugin.runtime.getSnapshot();
const credentials = this.plugin.channelCredentials.list();
const status = this.plugin.channelManager?.getChannelStatus(channel.name) ?? "disabled";
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const avatarEl = headerLeft.createDiv({ cls: `af-agent-card-avatar ${channelStatusToAvatarClass(status)}` });
setIcon(avatarEl, "radio");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Channel: ${channel.name}` });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: `Status: ${status}` });
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
// Form state pre-filled
const state = {
type: channel.type,
defaultAgent: channel.defaultAgent,
allowedAgents: [...channel.allowedAgents],
credentialRef: channel.credentialRef,
allowedUsers: channel.allowedUsers.join("\n"),
perUserSessions: channel.perUserSessions,
channelContext: channel.channelContext,
enabled: channel.enabled,
tags: channel.tags.join(", "),
body: channel.body,
transportJson: Object.keys(channel.transport).length > 0
? JSON.stringify(channel.transport, null, 2)
: "",
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Channel Details ───
const detailsSection = form.createDiv({ cls: "af-create-section" });
const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" });
const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(detailsIcon, "radio");
detailsHeader.createSpan({ text: "Channel Details" });
// Name (read-only)
const nameRow = detailsSection.createDiv({ cls: "af-form-row" });
nameRow.createDiv({ cls: "af-form-label", text: "Name" });
const nameInput = nameRow.createEl("input", {
cls: "af-form-input",
attr: { type: "text", value: channel.name, disabled: "true" },
});
nameInput.setCssStyles({ opacity: "0.6" });
// Type dropdown — only Slack is supported in v1; others shown as disabled
const typeRow = detailsSection.createDiv({ cls: "af-form-row" });
typeRow.createDiv({ cls: "af-form-label", text: "Type" });
const typeSelect = typeRow.createEl("select", { cls: "af-form-select" });
for (const t of ["slack", "telegram"] as const) {
const opt = typeSelect.createEl("option", { text: t, attr: { value: t } });
if (t === channel.type) opt.selected = true;
}
typeSelect.addEventListener("change", () => { state.type = typeSelect.value as ChannelConfig["type"]; });
// Credential dropdown
const credRow = detailsSection.createDiv({ cls: "af-form-row" });
const credLabel = credRow.createDiv({ cls: "af-form-label" });
credLabel.setText("Credential");
this.addTooltip(credLabel, "Configured in Settings → Agent Fleet → Channel Credentials");
const credSelect = credRow.createEl("select", { cls: "af-form-select" });
if (credentials.length === 0) {
credSelect.createEl("option", { text: "(no credentials configured)", attr: { value: "" } });
}
for (const c of credentials) {
const opt = credSelect.createEl("option", { text: `${c.ref} (${c.entry.type})`, attr: { value: c.ref } });
if (c.ref === channel.credentialRef) opt.selected = true;
}
credSelect.addEventListener("change", () => { state.credentialRef = credSelect.value; });
// Enabled toggle
const enabledRow = detailsSection.createDiv({ cls: "af-form-row af-form-row-toggle" });
enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
const enabledToggle = enabledRow.createDiv({ cls: `af-agent-card-toggle${channel.enabled ? " on" : ""}` });
enabledToggle.onclick = () => {
const isOn = enabledToggle.hasClass("on");
enabledToggle.toggleClass("on", !isOn);
state.enabled = !isOn;
};
// ─── Agent Routing ───
const agentSection = form.createDiv({ cls: "af-create-section" });
const agentHeader = agentSection.createDiv({ cls: "af-create-section-header" });
const agentIcon = agentHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(agentIcon, "bot");
agentHeader.createSpan({ text: "Agent Routing" });
// Default agent dropdown
const defaultRow = agentSection.createDiv({ cls: "af-form-row" });
const defaultLabel = defaultRow.createDiv({ cls: "af-form-label" });
defaultLabel.setText("Default agent");
this.addTooltip(defaultLabel, "Used when no @agent-name prefix is given in a message");
const defaultSelect = defaultRow.createEl("select", { cls: "af-form-select" });
for (const a of snapshot.agents) {
const opt = defaultSelect.createEl("option", { text: a.name, attr: { value: a.name } });
if (a.name === channel.defaultAgent) opt.selected = true;
}
defaultSelect.addEventListener("change", () => { state.defaultAgent = defaultSelect.value; });
// Allowed agents checkboxes
const allowedRow = agentSection.createDiv({ cls: "af-form-row" });
const allowedLabelEl = allowedRow.createDiv({ cls: "af-form-label" });
allowedLabelEl.setText("Allowed agents");
this.addTooltip(allowedLabelEl, "Agents reachable via @prefix. Leave unchecked to allow all agents.");
const checkboxContainer = allowedRow.createDiv({ cls: "af-form-checkboxes" });
for (const a of snapshot.agents) {
const label = checkboxContainer.createEl("label", { cls: "af-form-checkbox-label" });
const cb = label.createEl("input", { attr: { type: "checkbox", value: a.name } });
if (channel.allowedAgents.includes(a.name)) cb.checked = true;
label.appendText(` ${a.name}`);
cb.addEventListener("change", () => {
if (cb.checked) {
if (!state.allowedAgents.includes(a.name)) state.allowedAgents.push(a.name);
} else {
state.allowedAgents = state.allowedAgents.filter((n) => n !== a.name);
}
});
}
// Per-user sessions toggle
const perUserRow = agentSection.createDiv({ cls: "af-form-row af-form-row-toggle" });
const perUserLabel = perUserRow.createDiv({ cls: "af-form-label" });
perUserLabel.setText("Per-user sessions");
this.addTooltip(perUserLabel, "Each external user gets their own isolated Claude session");
const perUserToggle = perUserRow.createDiv({ cls: `af-agent-card-toggle${channel.perUserSessions ? " on" : ""}` });
perUserToggle.onclick = () => {
const isOn = perUserToggle.hasClass("on");
perUserToggle.toggleClass("on", !isOn);
state.perUserSessions = !isOn;
};
// ─── Access Control ───
const accessSection = form.createDiv({ cls: "af-create-section" });
const accessHeader = accessSection.createDiv({ cls: "af-create-section-header" });
const accessIconEl = accessHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(accessIconEl, "shield-check");
accessHeader.createSpan({ text: "Access Control" });
const usersLabelEl = accessSection.createDiv({ cls: "af-form-label" });
usersLabelEl.setText("Allowed users");
this.addTooltip(usersLabelEl, "Slack user IDs (U...), one per line. Only listed users can reach the bot.");
const usersTextarea = accessSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "U0AQW6P37N1\nU0BXYZ12345", rows: "4" },
});
usersTextarea.value = channel.allowedUsers.join("\n");
usersTextarea.addEventListener("input", () => { state.allowedUsers = usersTextarea.value; });
// ─── Channel Context ───
const contextSection = form.createDiv({ cls: "af-create-section" });
const contextHeader = contextSection.createDiv({ cls: "af-create-section-header" });
const contextIconEl = contextHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(contextIconEl, "message-square");
const contextHeaderLabel = contextHeader.createSpan({ text: "Channel Context" });
this.addTooltip(contextHeaderLabel, "Extra instructions appended to the agent's system prompt when reached through this channel");
const contextTextarea = contextSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "You are being contacted via Slack. Keep replies concise...", rows: "6" },
});
contextTextarea.value = channel.channelContext;
contextTextarea.addEventListener("input", () => { state.channelContext = contextTextarea.value; });
this.createFormField(detailsSection, "Tags", "ops, internal", "Comma-separated metadata", (v) => { state.tags = v; }, channel.tags.join(", "));
// ─── Advanced ───
const editAdvSection = form.createDiv({ cls: "af-create-section" });
const editAdvHeader = editAdvSection.createDiv({ cls: "af-create-section-header" });
const editAdvIcon = editAdvHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(editAdvIcon, "settings");
const editAdvHeaderLabel = editAdvHeader.createSpan({ text: "Advanced" });
this.addTooltip(editAdvHeaderLabel, "Markdown body (shown in the channel detail page) and transport-specific overrides");
const editBodyLabel = editAdvSection.createDiv({ cls: "af-form-label" });
editBodyLabel.setText("Body (markdown)");
const editBodyTextarea = editAdvSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "Notes, runbook snippets, escalation contacts…", rows: "4" },
});
editBodyTextarea.value = channel.body;
editBodyTextarea.addEventListener("input", () => { state.body = editBodyTextarea.value; });
const editTransportLabel = editAdvSection.createDiv({ cls: "af-form-label" });
editTransportLabel.setText("Transport config (JSON)");
this.addTooltip(editTransportLabel, "Optional JSON object for transport-specific overrides (e.g. Slack socket_mode). Leave blank for defaults.");
const editTransportTextarea = editAdvSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: '{\n "socket_mode": true\n}', rows: "4" },
});
editTransportTextarea.value = state.transportJson;
editTransportTextarea.addEventListener("input", () => { state.transportJson = editTransportTextarea.value; });
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" });
createIcon(deleteBtn, "trash-2", "af-btn-icon");
deleteBtn.appendText(" Delete");
deleteBtn.onclick = async () => {
await this.plugin.repository.deleteChannel(channel.name);
new Notice(`Channel "${channel.name}" deleted.`);
await new Promise((r) => window.setTimeout(r, 200));
await this.plugin.refreshFromVault();
this.navigate("channels");
};
footer.createDiv({ cls: "af-toolbar-spacer" });
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => this.navigate("channels");
const saveBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(saveBtn, "check", "af-btn-icon");
saveBtn.appendText(" Save Changes");
saveBtn.onclick = async () => {
const parseUsers = (s: string) => s.split(/[\n,]+/).map((t) => t.trim()).filter(Boolean);
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
let transport: Record<string, unknown> | undefined;
if (state.transportJson.trim()) {
try {
const parsed = JSON.parse(state.transportJson);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
transport = parsed as Record<string, unknown>;
} else {
new Notice("Transport config must be a JSON object.");
return;
}
} catch (err) {
new Notice(`Transport JSON is invalid: ${err instanceof Error ? err.message : String(err)}`);
return;
}
} else {
transport = {};
}
try {
await this.plugin.repository.updateChannel(channel.name, {
type: state.type,
default_agent: state.defaultAgent,
allowed_agents: state.allowedAgents.length > 0 ? state.allowedAgents : [],
enabled: state.enabled,
credential_ref: state.credentialRef,
allowed_users: parseUsers(state.allowedUsers),
per_user_sessions: state.perUserSessions,
channel_context: state.channelContext.trim(),
tags: parseTags(state.tags),
body: state.body.trim(),
transport,
});
new Notice(`Channel "${channel.name}" updated.`);
await this.plugin.refreshFromVault();
this.navigate("channels");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to update channel: ${msg}`);
}
};
}
// ═══════════════════════════════════════════════════════
// Approvals Page
// ═══════════════════════════════════════════════════════
private renderApprovalsPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-approvals-page" });
const runs = this.plugin.runtime.getRecentRuns();
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "Approvals" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
// Pending approvals
const pendingRuns = runs.filter((r) =>
(r.approvals ?? []).some((a) => a.status === "pending"),
);
if (pendingRuns.length > 0) {
const pendingSection = page.createDiv({ cls: "af-section-card" });
const pendingHeader = pendingSection.createDiv({ cls: "af-section-header" });
const pendingTitle = pendingHeader.createDiv({ cls: "af-section-title" });
createIcon(pendingTitle, "alert-triangle");
pendingTitle.appendText(` Pending (${pendingRuns.length})`);
const pendingBody = pendingSection.createDiv({ cls: "af-approvals-list" });
for (const run of pendingRuns) {
this.renderApprovalItem(pendingBody, run, true);
}
} else {
const emptySection = page.createDiv({ cls: "af-section-card" });
this.renderEmptyState(emptySection, "shield-check", "No pending approvals", "All clear!");
}
// Resolved approvals history
const resolvedRuns = runs.filter((r) =>
(r.approvals ?? []).some((a) => a.status !== "pending"),
);
if (resolvedRuns.length > 0) {
const resolvedSection = page.createDiv({ cls: "af-section-card" });
const resolvedHeader = resolvedSection.createDiv({ cls: "af-section-header" });
const resolvedTitle = resolvedHeader.createDiv({ cls: "af-section-title" });
createIcon(resolvedTitle, "check-circle-2");
resolvedTitle.appendText(" History");
const resolvedBody = resolvedSection.createDiv({ cls: "af-approvals-list" });
for (const run of resolvedRuns.slice(0, 20)) {
this.renderApprovalItem(resolvedBody, run, false);
}
}
}
private renderApprovalItem(container: HTMLElement, run: RunLogData, showActions: boolean): void {
for (const approval of run.approvals ?? []) {
if (showActions && approval.status !== "pending") continue;
if (!showActions && approval.status === "pending") continue;
const item = container.createDiv({ cls: "af-approval-item" });
const iconEl = item.createDiv({ cls: "af-approval-item-icon" });
if (approval.status === "pending") {
setIcon(iconEl, "shield-check");
iconEl.addClass("pending");
} else if (approval.status === "approved") {
setIcon(iconEl, "check-circle-2");
iconEl.addClass("approved");
} else {
setIcon(iconEl, "x-circle");
iconEl.addClass("rejected");
}
const body = item.createDiv({ cls: "af-approval-item-body" });
body.createDiv({
cls: "af-approval-item-title",
text: `${run.agent} \u2192 ${approval.tool}`,
});
body.createDiv({
cls: "af-approval-item-meta",
text: `Task: ${run.task} \u00B7 ${approval.command ?? "no command"} \u00B7 ${this.formatStarted(run.started)}`,
});
if (approval.reason) {
body.createDiv({ cls: "af-approval-item-reason", text: `Reason: ${approval.reason}` });
}
if (showActions && approval.status === "pending") {
const actions = item.createDiv({ cls: "af-approval-item-actions" });
const approveBtn = actions.createEl("button", { cls: "af-btn-approve" });
createIcon(approveBtn, "check-circle-2", "af-btn-icon");
approveBtn.appendText(" Approve");
approveBtn.onclick = () =>
void this.plugin.runtime
.resolveApproval(run, approval.tool, "approved")
.then(() => this.render());
const rejectBtn = actions.createEl("button", { cls: "af-btn-reject" });
createIcon(rejectBtn, "x-circle", "af-btn-icon");
rejectBtn.appendText(" Reject");
rejectBtn.onclick = () =>
void this.plugin.runtime
.resolveApproval(run, approval.tool, "rejected")
.then(() => this.render());
}
}
}
// ═══════════════════════════════════════════════════════
// Task Detail Page
// ═══════════════════════════════════════════════════════
private renderTaskDetailPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-task-detail-page" });
const taskId = this.detailContext;
if (!taskId) {
this.renderEmptyState(page, "circle-dot", "No task selected", "");
return;
}
const task = this.plugin.runtime.getSnapshot().tasks.find((t) => t.taskId === taskId);
if (!task) {
this.renderEmptyState(page, "circle-dot", "Task not found", `Task "${taskId}" was not found`);
return;
}
const snapshot = this.plugin.runtime.getSnapshot();
const runs = this.plugin.runtime.getRecentRuns().filter((r) => r.task === taskId);
const agent = snapshot.agents.find((a) => a.name === task.agent);
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const taskIcon = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(taskIcon, "circle-dot");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: task.taskId });
headerInfo.createDiv({
cls: "af-detail-header-desc",
text: `Agent: ${task.agent}`,
});
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
const editBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
createIcon(editBtn, "edit", "af-btn-icon");
editBtn.appendText(" Edit");
editBtn.onclick = () => this.navigate("edit-task", task.taskId);
const runBtn = headerActions.createEl("button", { cls: "af-btn-sm primary" });
createIcon(runBtn, "play", "af-btn-icon");
runBtn.appendText(" Run Now");
runBtn.onclick = () => void this.plugin.runtime.runTaskNow(task);
// Details section
const details = page.createDiv({ cls: "af-section-card" });
const detailsHeader = details.createDiv({ cls: "af-section-header" });
const detailsTitle = detailsHeader.createDiv({ cls: "af-section-title" });
createIcon(detailsTitle, "file-text");
detailsTitle.appendText(" Details");
const detailsBody = details.createDiv({ cls: "af-config-form" });
this.renderConfigRow(detailsBody, "Agent", task.agent);
this.renderConfigRow(detailsBody, "Priority", task.priority.charAt(0).toUpperCase() + task.priority.slice(1));
this.renderConfigRow(detailsBody, "Status", task.enabled ? "Enabled" : "Disabled");
// Schedule — human-readable, no raw cron
const scheduleText = task.schedule
? this.humanizeCron(task.schedule)
: task.runAt ?? "Manual (run on demand)";
this.renderConfigRow(detailsBody, "Schedule", scheduleText);
if (task.schedule) {
this.renderConfigRow(detailsBody, "Catch up if missed", task.catchUp ? "Yes" : "No");
}
this.renderConfigRow(detailsBody, "Created", task.created);
this.renderConfigRow(detailsBody, "Runs", String(task.runCount));
if (task.lastRun) {
this.renderConfigRow(detailsBody, "Last Run", this.formatStarted(task.lastRun));
}
// Instructions section
const promptSection = page.createDiv({ cls: "af-section-card" });
const promptHeader = promptSection.createDiv({ cls: "af-section-header" });
const promptTitle = promptHeader.createDiv({ cls: "af-section-title" });
createIcon(promptTitle, "message-square");
promptTitle.appendText(" Instructions");
promptSection.createDiv({ cls: "af-output-block", text: task.body || "(empty)" });
// Recent runs
const runsSection = page.createDiv({ cls: "af-section-card" });
const runsHeader = runsSection.createDiv({ cls: "af-section-header" });
const runsTitle = runsHeader.createDiv({ cls: "af-section-title" });
createIcon(runsTitle, "scroll-text");
runsTitle.appendText(" Recent Runs");
const runsBody = runsSection.createDiv({ cls: "af-timeline" });
if (runs.length === 0) {
this.renderEmptyState(runsBody, "scroll-text", "No runs yet", "");
} else {
for (const run of runs.slice(0, 10)) {
this.renderTimelineItem(runsBody, run);
}
}
}
// ═══════════════════════════════════════════════════════
// Search
// ═══════════════════════════════════════════════════════
private handleSearch(query: string, searchWrap: HTMLElement): void {
searchWrap.querySelector(".af-search-results")?.remove();
if (query.length < 2) return;
const q = query.toLowerCase();
const snapshot = this.plugin.runtime.getSnapshot();
const runs = this.plugin.runtime.getRecentRuns();
const results: Array<{ label: string; icon: string; action: () => void }> = [];
for (const agent of snapshot.agents) {
if (
agent.name.toLowerCase().includes(q) ||
(agent.description?.toLowerCase().includes(q) ?? false)
) {
results.push({
label: `Agent: ${agent.name}`,
icon: "bot",
action: () => this.navigate("agent-detail", agent.name),
});
}
}
for (const task of snapshot.tasks) {
if (task.taskId.toLowerCase().includes(q) || task.body.toLowerCase().includes(q)) {
results.push({
label: `Task: ${task.taskId}`,
icon: "circle-dot",
action: () => this.navigate("task-detail", task.taskId),
});
}
}
for (const skill of snapshot.skills) {
if (skill.name.toLowerCase().includes(q)) {
results.push({
label: `Skill: ${skill.name}`,
icon: "puzzle",
action: () => this.navigate("edit-skill", skill.name),
});
}
}
for (const run of runs.slice(0, 20)) {
if (run.output.toLowerCase().includes(q)) {
results.push({
label: `Run: ${run.agent} / ${run.task}`,
icon: "scroll-text",
action: () => this.openSlideover(run),
});
}
}
if (results.length === 0) return;
const dropdown = searchWrap.createDiv({ cls: "af-search-results" });
for (const result of results.slice(0, 10)) {
const item = dropdown.createDiv({ cls: "af-search-result-item" });
createIcon(item, result.icon, "af-search-result-icon");
item.createSpan({ text: result.label });
item.onclick = () => {
dropdown.remove();
result.action();
};
}
}
// ═══════════════════════════════════════════════════════
// Slideover (Run Details)
// ═══════════════════════════════════════════════════════
// Chat Slideover
// ═══════════════════════════════════════════════════════
public openChatSlideover(agent: AgentConfig): void {
void this.plugin.openChatView(agent.name);
}
// ═══════════════════════════════════════════════════════
private openSlideover(run: RunLogData): void {
this.contentEl.querySelector(".af-slideover-overlay")?.remove();
const overlay = this.contentEl.createDiv({ cls: "af-slideover-overlay" });
const panel = overlay.createDiv({ cls: "af-slideover" });
// Header
const header = panel.createDiv({ cls: "af-slideover-header" });
header.createDiv({ cls: "af-slideover-title", text: "Run Details" });
const closeBtn = header.createEl("button", { cls: "clickable-icon" });
setIcon(closeBtn, "cross");
closeBtn.onclick = () => overlay.remove();
// Body
const body = panel.createDiv({ cls: "af-slideover-body" });
// Metadata
const metaSection = body.createDiv({ cls: "af-slideover-section" });
metaSection.createDiv({ cls: "af-slideover-section-title", text: "METADATA" });
this.renderDetailRow(metaSection, "Run ID", run.runId.slice(0, 8));
this.renderDetailRow(metaSection, "Agent", run.agent);
this.renderDetailRow(metaSection, "Task", run.task);
const statusRow = metaSection.createDiv({ cls: "af-detail-row" });
statusRow.createSpan({ cls: "af-detail-label", text: "Status" });
const statusValue = statusRow.createSpan({ cls: "af-detail-value" });
const statusBadge = statusValue.createSpan({
cls: `af-status-badge ${this.statusToBadgeClass(run.status)}`,
});
const badgeIcon = statusBadge.createSpan();
setIcon(badgeIcon, this.statusToIconName(run.status));
statusBadge.appendText(` ${this.statusToBadgeText(run.status)}`);
this.renderDetailRow(metaSection, "Started", run.started);
this.renderDetailRow(metaSection, "Duration", this.formatDuration(run.durationSeconds));
this.renderDetailRow(metaSection, "Tokens", run.tokensUsed ? run.tokensUsed.toLocaleString() : "\u2014");
{
const sourceLabel: Record<string, string> = {
task: "from task override",
agent: "from agent",
settings: "from settings default",
"cli-default": "CLI default",
};
const suffix = run.modelSource ? ` (${sourceLabel[run.modelSource] ?? run.modelSource})` : "";
// Show the concrete model the CLI resolved to when it differs from the
// requested string — lets users trace "opus" → "claude-opus-4-7".
const concrete =
run.concreteModel && run.concreteModel !== run.model ? `${run.concreteModel}` : "";
this.renderDetailRow(metaSection, "Model", `${run.model}${concrete}${suffix}`);
}
// Output — focused on the final result. If the CLI's result event was
// captured (new runs), that short, narration-free answer is what the
// user sees here. The full assistant transcript collapses behind a
// `Show full transcript` disclosure so reasoning is still accessible
// but not the primary surface.
const MAX_RENDER_CHARS = 50000;
const cap = (s: string) =>
s.length > MAX_RENDER_CHARS
? s.slice(0, MAX_RENDER_CHARS) +
`\n\n---\n*Truncated (${(s.length / 1024).toFixed(0)} KB total). Open the run note for full content.*`
: s;
const hasFinalResult = !!(run.finalResult && run.finalResult.trim());
const outputForTranscript = run.output?.trim() ?? "";
const transcriptDiffersFromResult =
hasFinalResult &&
outputForTranscript.length > 0 &&
outputForTranscript !== run.finalResult!.trim();
if (hasFinalResult) {
const outputSection = body.createDiv({ cls: "af-slideover-section" });
outputSection.createDiv({ cls: "af-slideover-section-title", text: "OUTPUT" });
const resultBlock = outputSection.createDiv({ cls: "af-output-block af-compact-md" });
void MarkdownRenderer.render(this.app, cap(run.finalResult!), resultBlock, "", this);
if (transcriptDiffersFromResult) {
const details = outputSection.createEl("details", { cls: "af-run-transcript" });
const summary = details.createEl("summary");
setIcon(summary.createSpan({ cls: "af-run-transcript-icon" }), "file-text");
summary.createSpan({ text: "Show full transcript" });
const meta = summary.createSpan({ cls: "af-run-transcript-meta" });
meta.setText(`${(outputForTranscript.length / 1024).toFixed(1)} KB`);
const transcriptBlock = details.createDiv({ cls: "af-output-block af-compact-md af-run-transcript-body" });
void MarkdownRenderer.render(this.app, cap(outputForTranscript), transcriptBlock, "", this);
}
} else if (outputForTranscript) {
// Legacy runs without a captured finalResult: fall back to rendering
// the full output as today — no toggle to reveal something that
// doesn't exist.
const outputSection = body.createDiv({ cls: "af-slideover-section" });
outputSection.createDiv({ cls: "af-slideover-section-title", text: "OUTPUT" });
const outputBlock = outputSection.createDiv({ cls: "af-output-block af-compact-md" });
void MarkdownRenderer.render(this.app, cap(outputForTranscript), outputBlock, "", this);
}
// Tools used
if (run.toolsUsed.length > 0) {
const toolsSection = body.createDiv({ cls: "af-slideover-section" });
toolsSection.createDiv({ cls: "af-slideover-section-title", text: "TOOLS USED" });
toolsSection.createDiv({ cls: "af-output-block", text: run.toolsUsed.join("\n") });
}
// Actions
const actionsRow = body.createDiv({ cls: "af-slideover-actions" });
if (run.filePath) {
const openBtn = actionsRow.createEl("button", { cls: "af-btn-sm" });
createIcon(openBtn, "external-link", "af-btn-icon");
openBtn.appendText(" Open Run Note");
openBtn.onclick = () => void this.plugin.openPath(run.filePath!);
}
const rerunBtn = actionsRow.createEl("button", { cls: "af-btn-sm primary" });
createIcon(rerunBtn, "refresh-cw", "af-btn-icon");
rerunBtn.appendText(" Re-run Task");
rerunBtn.onclick = () => void this.plugin.runAgentPrompt(run.agent);
overlay.onclick = (e) => {
if (e.target === overlay) overlay.remove();
};
}
private renderDetailRow(container: HTMLElement, label: string, value: string): void {
const row = container.createDiv({ cls: "af-detail-row" });
row.createSpan({ cls: "af-detail-label", text: label });
row.createSpan({ cls: "af-detail-value af-mono", text: value });
}
// ═══════════════════════════════════════════════════════
// Empty State
// ═══════════════════════════════════════════════════════
private renderEmptyState(container: HTMLElement, iconName: string, label: string, sublabel: string): void {
const empty = container.createDiv({ cls: "af-empty-state" });
const iconEl = empty.createDiv({ cls: "af-empty-icon" });
setIcon(iconEl, iconName);
empty.createDiv({ cls: "af-empty-label", text: label });
if (sublabel) {
empty.createDiv({ cls: "af-empty-sublabel", text: sublabel });
}
}
// ═══════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════
private healthToClass(status: AgentHealth): string {
switch (status) {
case "running":
return "running";
case "error":
return "error";
case "pending":
return "pending";
case "disabled":
return "disabled";
default:
return "idle";
}
}
private statusToTimelineClass(status: string): string {
switch (status) {
case "success":
return "success";
case "failure":
case "timeout":
return "error";
case "cancelled":
return "warning";
case "pending_approval":
return "pending";
default:
return "running";
}
}
private statusToIconName(status: string): string {
switch (status) {
case "success":
return "check-circle-2";
case "failure":
return "x-circle";
case "timeout":
return "clock";
case "pending_approval":
return "shield-check";
case "cancelled":
return "square";
default:
return "loader-2";
}
}
private statusToBadgeClass(status: string): string {
switch (status) {
case "success":
return "success";
case "failure":
return "failure";
case "timeout":
return "timeout";
case "pending_approval":
return "pending";
case "cancelled":
return "cancelled";
default:
return "running";
}
}
private statusToBadgeText(status: string): string {
switch (status) {
case "success":
return "Success";
case "failure":
return "Failed";
case "timeout":
return "Timeout";
case "pending_approval":
return "Pending";
case "cancelled":
return "Cancelled";
case "interrupted":
return "Interrupted";
default:
return status;
}
}
private formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return s > 0 ? `${m}m ${s}s` : `${m}m`;
}
private formatStarted(iso: string): string {
try {
const d = new Date(iso);
const now = new Date();
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (d.toDateString() === yesterday.toDateString()) {
return `Yesterday ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
}
return (
d.toLocaleDateString([], { month: "short", day: "numeric" }) +
` ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
);
} catch {
return iso;
}
}
private formatNextRun(iso: string): string {
try {
const d = new Date(iso);
const now = new Date();
const diffMs = d.getTime() - now.getTime();
if (diffMs < 0) return "overdue";
const diffMin = Math.round(diffMs / 60000);
if (diffMin < 60) return `${diffMin}m`;
const diffH = Math.round(diffMin / 60);
if (diffH < 24) return `${diffH}h`;
return d.toLocaleDateString([], { month: "short", day: "numeric" });
} catch {
return iso;
}
}
private getNextTaskLabel(tasks: TaskConfig[]): string {
const nextRuns = tasks.map((t) => t.nextRun).filter(Boolean).sort();
const next = nextRuns[0];
if (!next) return "none";
const task = tasks.find((t) => t.nextRun === next);
return `${task?.agent ?? "unknown"} in ${this.formatNextRun(next)}`;
}
private getInitials(name: string): string {
return name
.split("-")
.map((w) => w[0]?.toUpperCase() ?? "")
.slice(0, 2)
.join("");
}
private renderAgentAvatar(el: HTMLElement, agent: AgentConfig): void {
const avatar = agent.avatar?.trim();
if (!avatar) {
setIcon(el, "bot");
return;
}
// Lucide icon names are lowercase alphanumeric + hyphens
if (/^[a-z][a-z0-9-]*$/.test(avatar)) {
setIcon(el, avatar);
} else {
el.setText(avatar);
}
}
private getSkillIcon(name: string): string {
if (name.includes("git")) return "settings";
if (name.includes("summarize") || name.includes("log")) return "activity";
if (name.includes("review") || name.includes("check")) return "check-circle-2";
if (name.includes("vault") || name.includes("note")) return "file-text";
return "puzzle";
}
private renderInlineSchedule(
container: HTMLElement,
state: { schedule: string; type: string },
): void {
// Parse current cron into components
const parsed = this.parseCronComponents(state.schedule);
// Frequency dropdown
const freqRow = container.createDiv({ cls: "af-form-row" });
freqRow.createDiv({ cls: "af-form-label", text: "Frequency" });
const freqSelect = freqRow.createEl("select", { cls: "af-form-select" });
const freqOptions: Array<[string, string]> = [
["every_5m", "Every 5 minutes"],
["every_15m", "Every 15 minutes"],
["every_30m", "Every 30 minutes"],
["every_hour", "Every hour"],
["every_2h", "Every 2 hours"],
["daily", "Daily"],
["weekdays", "Weekdays"],
["weekly", "Weekly"],
["monthly", "Monthly"],
];
for (const [val, lbl] of freqOptions) {
const opt = freqSelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === parsed.freq) opt.selected = true;
}
// Time row (shown for daily/weekdays/weekly/monthly)
const timeRow = container.createDiv({ cls: "af-form-row af-schedule-time-row" });
timeRow.createDiv({ cls: "af-form-label", text: "Time" });
const timeWrap = timeRow.createDiv({ cls: "af-schedule-time-selects" });
const hourSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" });
for (let h = 0; h < 24; h++) {
const ampm = h >= 12 ? "PM" : "AM";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
const opt = hourSelect.createEl("option", { text: `${h12} ${ampm}`, attr: { value: String(h) } });
if (h === parsed.hour) opt.selected = true;
}
timeWrap.createSpan({ cls: "af-schedule-colon", text: ":" });
const minSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" });
for (let m = 0; m < 60; m += 5) {
const opt = minSelect.createEl("option", {
text: String(m).padStart(2, "0"),
attr: { value: String(m) },
});
if (m === parsed.minute) opt.selected = true;
}
// Day row (shown for weekly)
const dayRow = container.createDiv({ cls: "af-form-row af-schedule-day-row" });
dayRow.createDiv({ cls: "af-form-label", text: "Day" });
const dayWrap = dayRow.createDiv({ cls: "af-schedule-day-buttons" });
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const selectedDays = new Set(parsed.days);
for (let d = 0; d < 7; d++) {
const btn = dayWrap.createEl("button", {
cls: `af-schedule-day-btn${selectedDays.has(d) ? " active" : ""}`,
text: dayNames[d]!,
});
btn.onclick = () => {
if (selectedDays.has(d)) selectedDays.delete(d);
else selectedDays.add(d);
btn.toggleClass("active", selectedDays.has(d));
buildCron();
};
}
// Day-of-month row (shown for monthly)
const domRow = container.createDiv({ cls: "af-form-row af-schedule-dom-row" });
domRow.createDiv({ cls: "af-form-label", text: "Day of month" });
const domSelect = domRow.createEl("select", { cls: "af-form-select af-form-select-sm" });
for (let d = 1; d <= 28; d++) {
const opt = domSelect.createEl("option", { text: String(d), attr: { value: String(d) } });
if (d === parsed.dayOfMonth) opt.selected = true;
}
// Visibility logic
const showHideFields = () => {
const freq = freqSelect.value;
const needsTime = ["daily", "weekdays", "weekly", "monthly"].includes(freq);
const needsDay = freq === "weekly";
const needsDom = freq === "monthly";
timeRow.setCssStyles({ display: needsTime ? "" : "none" });
dayRow.setCssStyles({ display: needsDay ? "" : "none" });
domRow.setCssStyles({ display: needsDom ? "" : "none" });
};
// Build cron from selections
const buildCron = () => {
const freq = freqSelect.value;
const h = hourSelect.value;
const m = minSelect.value;
let cron = "";
switch (freq) {
case "every_5m": cron = "*/5 * * * *"; break;
case "every_15m": cron = "*/15 * * * *"; break;
case "every_30m": cron = "*/30 * * * *"; break;
case "every_hour": cron = "0 * * * *"; break;
case "every_2h": cron = "0 */2 * * *"; break;
case "daily": cron = `${m} ${h} * * *`; break;
case "weekdays": cron = `${m} ${h} * * 1-5`; break;
case "weekly": {
const days = Array.from(selectedDays).sort().join(",") || "1";
cron = `${m} ${h} * * ${days}`;
break;
}
case "monthly": cron = `${m} ${h} ${domSelect.value} * *`; break;
}
state.schedule = cron;
state.type = "recurring";
};
freqSelect.addEventListener("change", () => { showHideFields(); buildCron(); });
hourSelect.addEventListener("change", buildCron);
minSelect.addEventListener("change", buildCron);
domSelect.addEventListener("change", buildCron);
showHideFields();
}
/**
* Simplified schedule picker for heartbeat — frequency intervals + time-of-day,
* no weekly/monthly/day-of-week selection. Outputs a cron expression into
* `state.heartbeatSchedule`.
*/
private renderHeartbeatSchedule(
container: HTMLElement,
state: { heartbeatSchedule: string },
): void {
const parsed = this.parseCronComponents(state.heartbeatSchedule);
// Frequency dropdown — intervals only, no weekly/monthly
const freqRow = container.createDiv({ cls: "af-form-row" });
freqRow.createDiv({ cls: "af-form-label", text: "Frequency" });
const freqSelect = freqRow.createEl("select", { cls: "af-form-select" });
const freqOptions: Array<[string, string]> = [
["every_5m", "Every 5 minutes"],
["every_15m", "Every 15 minutes"],
["every_30m", "Every 30 minutes"],
["every_hour", "Every hour"],
["every_2h", "Every 2 hours"],
["every_4h", "Every 4 hours"],
["every_6h", "Every 6 hours"],
["every_12h", "Every 12 hours"],
["daily", "Once a day"],
];
// Map the current schedule to a freq key
let currentFreq = "every_hour";
const cronToFreq: Record<string, string> = {
"*/5 * * * *": "every_5m",
"*/15 * * * *": "every_15m",
"*/30 * * * *": "every_30m",
"0 * * * *": "every_hour",
"0 */2 * * *": "every_2h",
"0 */4 * * *": "every_4h",
"0 */6 * * *": "every_6h",
"0 */12 * * *": "every_12h",
};
if (cronToFreq[state.heartbeatSchedule]) {
currentFreq = cronToFreq[state.heartbeatSchedule]!;
} else if (parsed.freq === "daily" || parsed.freq === "weekdays") {
currentFreq = "daily";
}
for (const [val, lbl] of freqOptions) {
const opt = freqSelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === currentFreq) opt.selected = true;
}
// Time row — only shown for "once a day"
const timeRow = container.createDiv({ cls: "af-form-row af-schedule-time-row" });
timeRow.createDiv({ cls: "af-form-label", text: "Time" });
const timeWrap = timeRow.createDiv({ cls: "af-schedule-time-selects" });
const hourSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" });
for (let h = 0; h < 24; h++) {
const ampm = h >= 12 ? "PM" : "AM";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
const opt = hourSelect.createEl("option", { text: `${h12} ${ampm}`, attr: { value: String(h) } });
if (h === parsed.hour) opt.selected = true;
}
timeWrap.createSpan({ cls: "af-schedule-colon", text: ":" });
const minSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" });
for (let m = 0; m < 60; m += 5) {
const opt = minSelect.createEl("option", {
text: String(m).padStart(2, "0"),
attr: { value: String(m) },
});
if (m === parsed.minute) opt.selected = true;
}
const showHide = () => {
timeRow.setCssStyles({ display: freqSelect.value === "daily" ? "" : "none" });
};
const buildCron = () => {
const freq = freqSelect.value;
const h = hourSelect.value;
const m = minSelect.value;
switch (freq) {
case "every_5m": state.heartbeatSchedule = "*/5 * * * *"; break;
case "every_15m": state.heartbeatSchedule = "*/15 * * * *"; break;
case "every_30m": state.heartbeatSchedule = "*/30 * * * *"; break;
case "every_hour": state.heartbeatSchedule = "0 * * * *"; break;
case "every_2h": state.heartbeatSchedule = "0 */2 * * *"; break;
case "every_4h": state.heartbeatSchedule = "0 */4 * * *"; break;
case "every_6h": state.heartbeatSchedule = "0 */6 * * *"; break;
case "every_12h": state.heartbeatSchedule = "0 */12 * * *"; break;
case "daily": state.heartbeatSchedule = `${m} ${h} * * *`; break;
}
};
freqSelect.addEventListener("change", () => { showHide(); buildCron(); });
hourSelect.addEventListener("change", buildCron);
minSelect.addEventListener("change", buildCron);
showHide();
}
private parseCronComponents(cron: string): {
freq: string;
hour: number;
minute: number;
days: number[];
dayOfMonth: number;
} {
const defaults = { freq: "daily", hour: 9, minute: 0, days: [1], dayOfMonth: 1 };
if (!cron?.trim()) return defaults;
const shortcutMap: Record<string, string> = {
"*/5 * * * *": "every_5m",
"*/15 * * * *": "every_15m",
"*/30 * * * *": "every_30m",
"0 * * * *": "every_hour",
"0 */2 * * *": "every_2h",
};
if (shortcutMap[cron]) return { ...defaults, freq: shortcutMap[cron]! };
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return defaults;
const [min, hr, dom, , dow] = parts;
const h = parseInt(hr ?? "9", 10);
const m = parseInt(min ?? "0", 10);
if (dom === "*" && dow === "*") return { ...defaults, freq: "daily", hour: h, minute: m };
if (dom === "*" && dow === "1-5") return { ...defaults, freq: "weekdays", hour: h, minute: m };
if (dom === "*" && dow !== "*") {
const days = (dow ?? "1").split(",").map((d) => parseInt(d, 10));
return { ...defaults, freq: "weekly", hour: h, minute: m, days };
}
if (dow === "*" && dom !== "*") {
return { ...defaults, freq: "monthly", hour: h, minute: m, dayOfMonth: parseInt(dom ?? "1", 10) };
}
return { ...defaults, hour: h, minute: m };
}
private humanizeCron(cron: string): string {
// Check shorthand aliases first
const shortcuts: Record<string, string> = {
"*/5 * * * *": "Every 5 minutes",
"*/10 * * * *": "Every 10 minutes",
"*/15 * * * *": "Every 15 minutes",
"*/30 * * * *": "Every 30 minutes",
"0 * * * *": "Every hour",
"0 */2 * * *": "Every 2 hours",
};
if (shortcuts[cron]) return shortcuts[cron];
// Also match shorthand input directly
const lower = cron.toLowerCase().trim();
if (lower.startsWith("every ")) return cron;
if (lower.startsWith("daily ") || lower === "daily") return cron;
if (lower.startsWith("hourly")) return "Every hour";
if (lower.startsWith("weekdays")) return cron;
if (lower.startsWith("weekly")) return cron;
if (lower.startsWith("monthly")) return cron;
// Parse standard 5-field cron
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return cron;
const [min, hr, dom, , dow] = parts;
const fmtTime = (h: string, m: string) => {
const hh = parseInt(h ?? "0", 10);
const mm = parseInt(m ?? "0", 10);
const ampm = hh >= 12 ? "PM" : "AM";
const h12 = hh === 0 ? 12 : hh > 12 ? hh - 12 : hh;
return mm === 0 ? `${h12} ${ampm}` : `${h12}:${String(mm).padStart(2, "0")} ${ampm}`;
};
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const fmtDow = (d: string) => {
if (d === "1-5") return "weekdays";
if (d === "0,6") return "weekends";
const nums = d.split(",").map((n) => parseInt(n, 10));
return nums.map((n) => dayNames[n] ?? n).join(", ");
};
if (hr === "*" && dom === "*" && dow === "*") {
if (min === "*") return "Every minute";
return `Every hour at :${String(min).padStart(2, "0")}`;
}
if (dom === "*" && dow === "*" && hr !== "*") {
return `Daily at ${fmtTime(hr ?? "0", min ?? "0")}`;
}
if (dom === "*" && dow === "1-5" && hr !== "*") {
return `Weekdays at ${fmtTime(hr ?? "0", min ?? "0")}`;
}
if (dom === "*" && dow !== "*" && hr !== "*") {
return `${fmtDow(dow ?? "1")} at ${fmtTime(hr ?? "0", min ?? "0")}`;
}
if (dow === "*" && dom !== "*" && hr !== "*") {
return `Monthly on the ${dom} at ${fmtTime(hr ?? "0", min ?? "0")}`;
}
return cron;
}
private getTagClass(tag: string): string {
if (tag === "monitoring") return "monitoring";
if (tag === "devops") return "devops";
if (tag === "sample") return "sample";
return "default";
}
// ═══════════════════════════════════════════════════════
// Create Agent Page
// ═══════════════════════════════════════════════════════
private renderCreateAgentPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-create-agent-page" });
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(avatar, "plus");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Agent" });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Configure a new agent for your fleet" });
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
// Form state
const state = {
name: "",
description: "",
avatar: "",
tags: "",
systemPrompt: "",
model: "default",
adapter: "claude-code",
cwd: "",
timeout: 300,
permissionMode: "bypassPermissions",
effort: "",
selectedSkills: new Set<string>(),
selectedMcpServers: new Set<string>(),
skillsBody: "",
contextBody: "",
approvalRequired: "",
memory: true,
enabled: true,
allowedCommands: "",
blockedCommands: "",
heartbeatEnabled: false,
heartbeatSchedule: "0 */6 * * *",
heartbeatBody: "",
heartbeatNotify: true,
heartbeatChannel: "",
autoCompactThreshold: 85,
wikiReferences: [] as string[],
};
const TEMPLATES: Record<string, { label: string; prompt: string }> = {
none: { label: "None", prompt: "" },
coding: { label: "Coding Agent", prompt: "You are a coding agent. Review code, write tests, fix bugs, and implement features.\nFollow existing code conventions. Write clean, well-tested code.\nIf something is unclear, ask for clarification instead of guessing." },
monitor: { label: "Monitor", prompt: "You are a monitoring agent. Check system status, alert on failures, and report on health metrics.\nBe concise and factual. Highlight anomalies clearly.\nInclude timestamps and relevant context in all reports." },
briefing: { label: "Briefing", prompt: "You are a briefing agent. Summarize activity, generate reports, and surface key changes.\nPrioritize recent and important changes. Keep summaries concise.\nEnd with explicit next actions if they exist." },
reviewer: { label: "Code Reviewer", prompt: "You are a code review agent. Analyze pull requests, suggest improvements, and flag potential issues.\nFocus on correctness, security, and maintainability.\nBe specific — reference file names and line numbers." },
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Identity Section ───
const identitySection = form.createDiv({ cls: "af-create-section" });
const identityHeader = identitySection.createDiv({ cls: "af-create-section-header" });
const identityIcon = identityHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(identityIcon, "user");
identityHeader.createSpan({ text: "Identity" });
this.createFormField(identitySection, "Name", "deploy-watcher", "Unique identifier (will be slugified)", (v) => { state.name = v; });
this.createFormField(identitySection, "Description", "Monitors deployments and reports status", "", (v) => { state.description = v; });
const avatarRow = identitySection.createDiv({ cls: "af-form-row" });
avatarRow.createDiv({ cls: "af-form-label", text: "Avatar" });
const avatarInput = avatarRow.createEl("input", {
cls: "af-form-input af-form-input-sm",
attr: { type: "text", placeholder: "🛡️" },
});
avatarInput.addEventListener("input", () => { state.avatar = avatarInput.value; });
this.createFormField(identitySection, "Tags", "devops, monitoring", "Comma-separated", (v) => { state.tags = v; });
// Enabled toggle
const enabledRow = identitySection.createDiv({ cls: "af-form-row af-form-row-toggle" });
enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
const enabledToggle = enabledRow.createDiv({ cls: "af-agent-card-toggle on" });
enabledToggle.onclick = () => {
const isOn = enabledToggle.hasClass("on");
enabledToggle.toggleClass("on", !isOn);
state.enabled = !isOn;
};
// ─── System Prompt Section ───
const promptSection = form.createDiv({ cls: "af-create-section" });
const promptHeader = promptSection.createDiv({ cls: "af-create-section-header" });
const promptIcon = promptHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(promptIcon, "message-square");
promptHeader.createSpan({ text: "System Prompt" });
const templateRow = promptSection.createDiv({ cls: "af-form-row" });
templateRow.createDiv({ cls: "af-form-label", text: "Template" });
const templateSelect = templateRow.createEl("select", { cls: "af-form-select" });
for (const [key, { label }] of Object.entries(TEMPLATES)) {
templateSelect.createEl("option", { text: label, attr: { value: key } });
}
const promptTextarea = promptSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "You are a deployment monitoring agent...", rows: "10" },
});
promptTextarea.addEventListener("input", () => { state.systemPrompt = promptTextarea.value; });
templateSelect.addEventListener("change", () => {
const preset = TEMPLATES[templateSelect.value];
if (preset && templateSelect.value !== "none") {
state.systemPrompt = preset.prompt;
promptTextarea.value = preset.prompt;
}
});
// ─── Runtime Config Section ───
const configSection = form.createDiv({ cls: "af-create-section" });
const configHeader = configSection.createDiv({ cls: "af-create-section-header" });
const configIcon = configHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(configIcon, "settings");
configHeader.createSpan({ text: "Runtime Config" });
const configGrid = configSection.createDiv({ cls: "af-create-config-grid" });
// Adapter (before model, so model dropdown updates when adapter changes)
const adapterRow = configGrid.createDiv({ cls: "af-form-row" });
adapterRow.createDiv({ cls: "af-form-label", text: "Adapter" });
const adapterSelect = adapterRow.createEl("select", { cls: "af-form-select" });
for (const [val, lbl, disabled] of ADAPTER_FORM_OPTIONS) {
const opt = adapterSelect.createEl("option", { text: lbl, attr: { value: val, ...(disabled ? { disabled: "true" } : {}) } });
if (val === "claude-code") opt.selected = true;
}
// Model (dynamic based on adapter)
const modelRow = configGrid.createDiv({ cls: "af-form-row" });
const modelLabel = modelRow.createDiv({ cls: "af-form-label", text: "Model" });
this.addTooltip(
modelLabel,
`Aliases (opus/sonnet/haiku/opusplan) work on any backend. Choose Custom\u2026 for a pinned ID or Bedrock/Vertex/Foundry. Blank = use Settings default (${this.plugin.settings.defaultModel || "CLI default"}).`,
);
const modelFieldWrap = modelRow.createDiv({ cls: "af-form-field-wrap" });
const renderModelField = () => {
renderModelPicker(modelFieldWrap, {
value: state.model,
adapter: state.adapter,
onChange: (value) => { state.model = value; },
});
};
renderModelField();
// The permission dropdown is built further down; it registers its
// repopulate hook here so adapter switches refresh it too.
let repopulatePermModeSelect: () => void = () => { /* assigned below */ };
adapterSelect.addEventListener("change", () => {
state.adapter = adapterSelect.value;
// A model alias from the other vendor would be passed verbatim and
// rejected by the CLI \u2014 reset to "use default" on family switch.
const otherAliases = isCodexAdapterValue(state.adapter) ? MODEL_ALIASES : CODEX_MODEL_ALIASES;
if (otherAliases.some((a) => a.value === state.model.trim())) {
state.model = "";
}
renderModelField();
state.permissionMode = permModeForAdapter(state.permissionMode, state.adapter);
repopulatePermModeSelect();
});
// Working Dir
const cwdRow = configGrid.createDiv({ cls: "af-form-row" });
cwdRow.createDiv({ cls: "af-form-label", text: "Working Dir" });
const cwdInput = cwdRow.createEl("input", {
cls: "af-form-input",
attr: { type: "text", placeholder: "Leave empty for vault root" },
});
cwdInput.addEventListener("input", () => { state.cwd = cwdInput.value; });
// Timeout
const timeoutRow = configGrid.createDiv({ cls: "af-form-row" });
timeoutRow.createDiv({ cls: "af-form-label", text: "Timeout (sec)" });
const timeoutInput = timeoutRow.createEl("input", {
cls: "af-form-input af-form-input-sm",
attr: { type: "number", value: "300" },
});
timeoutInput.addEventListener("input", () => {
const n = parseInt(timeoutInput.value, 10);
if (!isNaN(n) && n > 0) state.timeout = n;
});
// Permission Mode (options depend on the selected adapter)
const permRow = configGrid.createDiv({ cls: "af-form-row" });
permRow.createDiv({ cls: "af-form-label", text: "Permission Mode" });
const permSelect = permRow.createEl("select", { cls: "af-form-select" });
const permDescEl = configGrid.createDiv({ cls: "af-form-hint", text: "" });
repopulatePermModeSelect = () => {
// Snap any value left over from the other adapter family to its
// nearest equivalent so the dropdown always reflects what gets saved.
state.permissionMode = permModeForAdapter(state.permissionMode, state.adapter);
const options = permModeOptionsFor(state.adapter);
permSelect.empty();
for (const [val, lbl] of options) {
const opt = permSelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === state.permissionMode) opt.selected = true;
}
permDescEl.textContent = options.find(([v]) => v === permSelect.value)?.[2] ?? "";
};
repopulatePermModeSelect();
permSelect.addEventListener("change", () => {
state.permissionMode = permSelect.value;
permDescEl.textContent = permModeOptionsFor(state.adapter).find(([v]) => v === permSelect.value)?.[2] ?? "";
});
// Effort Level
const effortRow = configGrid.createDiv({ cls: "af-form-row" });
effortRow.createDiv({ cls: "af-form-label", text: "Effort Level" });
const effortSelect = effortRow.createEl("select", { cls: "af-form-select" });
for (const [val, lbl] of [["", "Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"]] as const) {
effortSelect.createEl("option", { text: lbl, attr: { value: val } });
}
effortSelect.addEventListener("change", () => { state.effort = effortSelect.value; });
configGrid.createDiv({ cls: "af-form-hint", text: "Controls reasoning depth — low is fast, max is most thorough" });
// Auto-compact threshold
const compactRow = configGrid.createDiv({ cls: "af-form-row" });
const compactLabel = compactRow.createDiv({ cls: "af-form-label", text: "Auto-compact at" });
this.addTooltip(
compactLabel,
"Percent of context window at which the chat auto-invokes /compact before the next message. 85% is a good default. Set 0 to disable.",
);
const compactInput = compactRow.createEl("input", {
cls: "af-form-input af-form-input-sm",
attr: { type: "number", min: "0", max: "100", value: String(state.autoCompactThreshold) },
});
compactInput.addEventListener("input", () => {
const n = parseInt(compactInput.value, 10);
if (!isNaN(n) && n >= 0 && n <= 100) state.autoCompactThreshold = n;
});
configGrid.createDiv({ cls: "af-form-hint", text: "0 disables auto-compact" });
// Wiki references (consumer-mode read access to other keepers' scopes)
{
const wikiKeepers = this.plugin.runtime.getSnapshot().agents.filter((a) => a.wikiKeeper !== undefined);
if (wikiKeepers.length > 0) {
const wrRow = configGrid.createDiv({ cls: "af-form-row af-form-row-toggle" });
const wrLabel = wrRow.createDiv({ cls: "af-form-label", text: "Wiki access" });
this.addTooltip(
wrLabel,
"Lets this agent read + cite from the selected Wiki Keeper scopes (requires the wiki-query skill).",
);
const wrWrap = wrRow.createDiv({ cls: "af-form-field-wrap" });
for (const wk of wikiKeepers) {
const label = wrWrap.createEl("label", { cls: "af-form-checkbox-row" });
const cb = label.createEl("input", { attr: { type: "checkbox" } }) as HTMLInputElement;
label.createSpan({ text: ` ${wk.name}`, cls: "af-form-checkbox-label" });
cb.addEventListener("change", () => {
if (cb.checked) {
if (!state.wikiReferences.includes(wk.name)) state.wikiReferences.push(wk.name);
} else {
state.wikiReferences = state.wikiReferences.filter((n) => n !== wk.name);
}
});
}
}
}
// ─── Heartbeat Section ───
{
const heartbeatSection = form.createDiv({ cls: "af-create-section" });
const heartbeatHeader = heartbeatSection.createDiv({ cls: "af-create-section-header" });
const heartbeatIcon = heartbeatHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(heartbeatIcon, "heart-pulse");
const heartbeatHeaderLabel = heartbeatHeader.createSpan({ text: "Heartbeat" });
this.addTooltip(heartbeatHeaderLabel, "Autonomous periodic run — what the agent does when no one is asking");
const hbEnabledRow = heartbeatSection.createDiv({ cls: "af-form-row af-form-row-toggle" });
hbEnabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
const hbEnabledToggle = hbEnabledRow.createDiv({ cls: "af-agent-card-toggle" });
const hbBody = heartbeatSection.createDiv();
hbBody.setCssStyles({ display: "none" });
hbEnabledToggle.onclick = () => {
const isOn = hbEnabledToggle.hasClass("on");
hbEnabledToggle.toggleClass("on", !isOn);
state.heartbeatEnabled = !isOn;
hbBody.setCssStyles({ display: !isOn ? "" : "none" });
};
this.renderHeartbeatSchedule(hbBody, state);
const hbNotifyRow = hbBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
const hbNotifyLabel = hbNotifyRow.createDiv({ cls: "af-form-label" });
hbNotifyLabel.setText("Notify");
this.addTooltip(hbNotifyLabel, "Show an Obsidian notice when the heartbeat completes");
const hbNotifyToggle = hbNotifyRow.createDiv({ cls: "af-agent-card-toggle on" });
hbNotifyToggle.onclick = () => {
const isOn = hbNotifyToggle.hasClass("on");
hbNotifyToggle.toggleClass("on", !isOn);
state.heartbeatNotify = !isOn;
};
const createSnapshot = this.plugin.runtime.getSnapshot();
const hbChannelRow = hbBody.createDiv({ cls: "af-form-row" });
const hbChannelLabel = hbChannelRow.createDiv({ cls: "af-form-label" });
hbChannelLabel.setText("Post to channel");
this.addTooltip(hbChannelLabel, "Heartbeat results are posted to this Slack channel when the run completes");
const hbChannelSelect = hbChannelRow.createEl("select", { cls: "af-form-select" });
hbChannelSelect.createEl("option", { text: "(none)", attr: { value: "" } });
for (const ch of createSnapshot.channels) {
hbChannelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } });
}
hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; });
const hbInstructionLabel = hbBody.createDiv({ cls: "af-form-label" });
hbInstructionLabel.setCssStyles({ width: "auto" });
hbInstructionLabel.setCssStyles({ marginTop: "12px" });
hbInstructionLabel.setText("Instruction");
this.addTooltip(hbInstructionLabel, "What the agent does on each heartbeat. Also used by the \"Run Now\" button.");
const hbTextarea = hbBody.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "Check status, scan for issues, report findings...", rows: "8" },
});
hbTextarea.addEventListener("input", () => { state.heartbeatBody = hbTextarea.value; });
}
// ─── Skills Section ───
const skillsSection = form.createDiv({ cls: "af-create-section" });
const skillsHeader = skillsSection.createDiv({ cls: "af-create-section-header" });
const skillsIcon = skillsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(skillsIcon, "puzzle");
skillsHeader.createSpan({ text: "Skills" });
const snapshot = this.plugin.runtime.getSnapshot();
if (snapshot.skills.length > 0) {
skillsSection.createDiv({ cls: "af-form-sublabel", text: "Shared Skills" });
const skillsGrid = skillsSection.createDiv({ cls: "af-create-skills-grid" });
for (const skill of snapshot.skills) {
const item = skillsGrid.createDiv({ cls: "af-create-skill-item" });
const cb = item.createEl("input", { cls: "af-form-toggle", attr: { type: "checkbox" } });
cb.addEventListener("change", () => {
if (cb.checked) state.selectedSkills.add(skill.name);
else state.selectedSkills.delete(skill.name);
});
const lbl = item.createDiv({ cls: "af-create-skill-label" });
lbl.createSpan({ cls: "af-create-skill-name", text: skill.name });
if (skill.description) {
lbl.createSpan({ cls: "af-create-skill-desc", text: `${skill.description}` });
}
}
}
const agentSkillsLabel = skillsSection.createDiv({ cls: "af-form-sublabel" });
agentSkillsLabel.setText("Agent-specific skills");
this.addTooltip(agentSkillsLabel, "Custom skills/instructions only for this agent, not shared with others");
const skillsTextarea = skillsSection.createEl("textarea", {
cls: "af-create-textarea",
attr: { placeholder: "Custom skills/instructions for this agent...", rows: "4" },
});
skillsTextarea.addEventListener("input", () => { state.skillsBody = skillsTextarea.value; });
// ─── MCP Servers Section ───
{
const mcpSection = form.createDiv({ cls: "af-create-section" });
const mcpHeader = mcpSection.createDiv({ cls: "af-create-section-header" });
const mcpIcon = mcpHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(mcpIcon, "plug");
const mcpHeaderLabel = mcpHeader.createSpan({ text: "MCP Servers" });
this.addTooltip(mcpHeaderLabel, "Grant agent access to MCP servers");
this.renderAgentMcpPicker(mcpSection, state.selectedMcpServers);
}
// ─── Context Section ───
const contextSection = form.createDiv({ cls: "af-create-section" });
const contextHeader = contextSection.createDiv({ cls: "af-create-section-header" });
const contextIcon = contextHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(contextIcon, "file-text");
const contextHeaderLabel = contextHeader.createSpan({ text: "Context" });
this.addTooltip(contextHeaderLabel, "Project-specific context included in every run");
const contextTextarea = contextSection.createEl("textarea", {
cls: "af-create-textarea",
attr: { placeholder: "Background info, repo structure, conventions...", rows: "4" },
});
contextTextarea.addEventListener("input", () => { state.contextBody = contextTextarea.value; });
// ─── Permissions Section ───
const permsSection = form.createDiv({ cls: "af-create-section" });
const permsHeader = permsSection.createDiv({ cls: "af-create-section-header" });
const permsIcon = permsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(permsIcon, "shield-check");
permsHeader.createSpan({ text: "Permissions" });
this.createFormField(permsSection, "Approval required", "git_push, file_delete", "Comma-separated tool names", (v) => { state.approvalRequired = v; });
const allowRow = permsSection.createDiv({ cls: "af-form-row" });
allowRow.createDiv({ cls: "af-form-label", text: "Allowed Commands" });
const allowTextarea = allowRow.createEl("textarea", {
cls: "af-create-textarea",
attr: { placeholder: "Bash(curl *)\nBash(python3 *)\nRead\nEdit\nWrite", rows: "4" },
});
allowTextarea.addEventListener("input", () => { state.allowedCommands = allowTextarea.value; });
const denyRow = permsSection.createDiv({ cls: "af-form-row" });
denyRow.createDiv({ cls: "af-form-label", text: "Blocked Commands" });
const denyTextarea = denyRow.createEl("textarea", {
cls: "af-create-textarea",
attr: { placeholder: "Bash(git push *)\nBash(rm -rf *)\nBash(sudo *)", rows: "4" },
});
denyTextarea.addEventListener("input", () => { state.blockedCommands = denyTextarea.value; });
permsSection.createDiv({
cls: "af-form-hint",
text:
"On Codex agents these become execpolicy command rules — only Bash(cmd args *) " +
"prefixes are enforced; tool-name rules (Read/Write) and mid-pattern wildcards are " +
"ignored, and file/network access is governed by Permission Mode (the sandbox).",
});
const memoryRow = permsSection.createDiv({ cls: "af-form-row" });
memoryRow.createDiv({ cls: "af-form-label", text: "Memory enabled" });
const memoryToggle = memoryRow.createDiv({ cls: "af-agent-card-toggle on" });
memoryToggle.onclick = () => {
const isOn = memoryToggle.hasClass("on");
memoryToggle.toggleClass("on", !isOn);
state.memory = !isOn;
};
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => this.navigate("agents");
const createBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(createBtn, "plus", "af-btn-icon");
createBtn.appendText(" Create Agent");
createBtn.onclick = async () => {
const name = state.name.trim();
if (!name) {
new Notice("Agent name is required.");
return;
}
const slug = slugify(name);
if (this.plugin.repository.getAgentByName(slug)) {
new Notice(`Agent "${slug}" already exists.`);
return;
}
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
try {
const parseLines = (s: string) => splitLines(s).map((l) => l.trim()).filter(Boolean);
await this.plugin.repository.createAgentFolder({
name: slug,
description: state.description.trim(),
avatar: state.avatar.trim(),
tags: parseTags(state.tags),
systemPrompt: state.systemPrompt.trim(),
model: state.model.trim() || "default",
adapter: state.adapter,
cwd: state.cwd.trim(),
timeout: state.timeout,
permissionMode: state.permissionMode,
effort: state.effort || undefined,
approvalRequired: parseTags(state.approvalRequired),
memory: state.memory,
memoryMaxEntries: 100,
skills: Array.from(state.selectedSkills),
mcpServers: Array.from(state.selectedMcpServers),
skillsBody: state.skillsBody.trim(),
contextBody: state.contextBody.trim(),
enabled: state.enabled,
permissionRules: {
allow: parseLines(state.allowedCommands),
deny: parseLines(state.blockedCommands),
},
autoCompactThreshold: state.autoCompactThreshold,
wikiReferences: state.wikiReferences,
});
// Save heartbeat if configured
if (state.heartbeatEnabled && state.heartbeatBody.trim()) {
await this.plugin.repository.updateHeartbeat(slug, {
enabled: state.heartbeatEnabled,
schedule: state.heartbeatSchedule.trim(),
notify: state.heartbeatNotify,
channel: state.heartbeatChannel,
body: state.heartbeatBody.trim(),
});
}
new Notice(`Agent "${slug}" created.`);
await this.plugin.refreshFromVault();
this.navigate("agent-detail", slug);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to create agent: ${msg}`);
}
};
}
// ═══════════════════════════════════════════════════════
// Create Skill Page
// ═══════════════════════════════════════════════════════
private renderCreateSkillPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-create-agent-page" });
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(avatar, "plus");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Skill" });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Define a reusable skill for your agents" });
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
// Form state
const state = {
name: "",
description: "",
tags: "",
body: "",
toolsBody: "",
referencesBody: "",
examplesBody: "",
};
const SKILL_TEMPLATES: Record<string, { label: string; prompt: string }> = {
none: { label: "None", prompt: "" },
cli: { label: "CLI Tool Wrapper", prompt: "You are using the {{tool}} CLI. All operations go through the wrapper script.\n\nRequirements:\n- Ensure required environment variables are set\n- Parse JSON responses for human-readable output\n- Confirm destructive operations before executing\n\nKey behaviors:\n- List existing items before making changes\n- Use --dry-run flags when available\n- Report errors clearly with suggested fixes" },
api: { label: "API Integration", prompt: "You are integrating with the {{service}} API.\n\nBase URL: https://api.example.com/v1\nAuth: Bearer token via environment variable\n\nKey behaviors:\n- Always check rate limits before bulk operations\n- Handle pagination for list endpoints\n- Validate inputs before making requests\n- Parse and format JSON responses for readability" },
review: { label: "Code Review", prompt: "You are a code review skill. Analyze code changes and provide structured feedback.\n\nReview checklist:\n- Correctness: Does the code do what it claims?\n- Security: Any injection, auth, or data exposure risks?\n- Performance: Unnecessary allocations, N+1 queries, missing indexes?\n- Maintainability: Clear naming, reasonable complexity, adequate tests?\n\nOutput format:\n- Start with a 1-line summary\n- Group findings by severity (critical, warning, suggestion)\n- Reference specific file paths and line numbers" },
data: { label: "Data Analysis", prompt: "You are a data analysis skill. Query, transform, and report on data.\n\nKey behaviors:\n- Summarize datasets before diving into details\n- Use tables and charts where appropriate\n- Always state the time range and filters applied\n- Flag anomalies and outliers explicitly\n- End with actionable insights, not just observations" },
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Identity Section ───
const identitySection = form.createDiv({ cls: "af-create-section" });
const identityHeader = identitySection.createDiv({ cls: "af-create-section-header" });
const identityIcon = identityHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(identityIcon, "puzzle");
identityHeader.createSpan({ text: "Identity" });
this.createFormField(identitySection, "Name", "todoist", "Unique identifier (will be slugified)", (v) => { state.name = v; });
this.createFormField(identitySection, "Description", "Manage tasks and projects via CLI", "", (v) => { state.description = v; });
this.createFormField(identitySection, "Tags", "productivity, tasks", "Comma-separated", (v) => { state.tags = v; });
// ─── Core Instructions Section ───
const instructionsSection = form.createDiv({ cls: "af-create-section" });
const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" });
const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(instructionsIcon, "file-text");
instructionsHeader.createSpan({ text: "Core Instructions" });
const templateRow = instructionsSection.createDiv({ cls: "af-form-row" });
templateRow.createDiv({ cls: "af-form-label", text: "Template" });
const templateSelect = templateRow.createEl("select", { cls: "af-form-select" });
for (const [key, { label }] of Object.entries(SKILL_TEMPLATES)) {
templateSelect.createEl("option", { text: label, attr: { value: key } });
}
const bodyTextarea = instructionsSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "Skill instructions — what does this skill do and how should agents use it?", rows: "10" },
});
bodyTextarea.addEventListener("input", () => { state.body = bodyTextarea.value; });
templateSelect.addEventListener("change", () => {
const preset = SKILL_TEMPLATES[templateSelect.value];
if (preset && templateSelect.value !== "none") {
state.body = preset.prompt;
bodyTextarea.value = preset.prompt;
}
});
// ─── Tools Section ───
const toolsSection = form.createDiv({ cls: "af-create-section" });
const toolsHeader = toolsSection.createDiv({ cls: "af-create-section-header" });
const toolsIcon = toolsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(toolsIcon, "wrench");
const toolsHeaderLabel = toolsHeader.createSpan({ text: "Tools" });
this.addTooltip(toolsHeaderLabel, "CLI commands, API endpoints, and tool definitions available to agents using this skill");
const toolsTextarea = toolsSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "## Commands\n\n### list\nUsage: tool list [--filter <query>]\n...", rows: "8" },
});
toolsTextarea.addEventListener("input", () => { state.toolsBody = toolsTextarea.value; });
// ─── References Section ───
const refsSection = form.createDiv({ cls: "af-create-section" });
const refsHeader = refsSection.createDiv({ cls: "af-create-section-header" });
const refsIcon = refsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(refsIcon, "book-open");
const refsHeaderLabel = refsHeader.createSpan({ text: "References" });
this.addTooltip(refsHeaderLabel, "Background docs, conventions, cheat sheets");
const refsTextarea = refsSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "API docs, filter syntax, conventions...", rows: "6" },
});
refsTextarea.addEventListener("input", () => { state.referencesBody = refsTextarea.value; });
// ─── Examples Section ───
const examplesSection = form.createDiv({ cls: "af-create-section" });
const examplesHeader = examplesSection.createDiv({ cls: "af-create-section-header" });
const examplesIcon = examplesHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(examplesIcon, "message-circle");
const examplesHeaderLabel = examplesHeader.createSpan({ text: "Examples" });
this.addTooltip(examplesHeaderLabel, "Example prompts and ideal outputs showing how to use this skill");
const examplesTextarea = examplesSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "## Example: List all tasks\n\nUser: Show me my tasks for today\n\nAgent: ...", rows: "6" },
});
examplesTextarea.addEventListener("input", () => { state.examplesBody = examplesTextarea.value; });
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => this.navigate("skills");
const createBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(createBtn, "plus", "af-btn-icon");
createBtn.appendText(" Create Skill");
createBtn.onclick = async () => {
const name = state.name.trim();
if (!name) {
new Notice("Skill name is required.");
return;
}
const slug = slugify(name);
if (this.plugin.repository.getSkillByName(slug)) {
new Notice(`Skill "${slug}" already exists.`);
return;
}
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
try {
await this.plugin.repository.createSkillFolder({
name: slug,
description: state.description.trim(),
tags: parseTags(state.tags),
body: state.body.trim(),
toolsBody: state.toolsBody.trim(),
referencesBody: state.referencesBody.trim(),
examplesBody: state.examplesBody.trim(),
});
new Notice(`Skill "${slug}" created.`);
await this.plugin.refreshFromVault();
this.navigate("skills");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to create skill: ${msg}`);
}
};
}
// ═══════════════════════════════════════════════════════
// Edit Agent Page
// ═══════════════════════════════════════════════════════
private renderEditAgentPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-create-agent-page" });
const agentName = this.detailContext;
if (!agentName) {
this.renderEmptyState(page, "bot", "No agent selected", "");
return;
}
const agent = this.plugin.runtime.getSnapshot().agents.find((a) => a.name === agentName);
if (!agent) {
this.renderEmptyState(page, "bot", "Agent not found", `Agent "${agentName}" was not found`);
return;
}
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const avatarEl = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(avatarEl, "edit");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Agent: ${agent.name}` });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Modify agent configuration" });
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
// Form state pre-filled
const state = {
name: agent.name,
description: agent.description ?? "",
avatar: agent.avatar,
tags: agent.tags.join(", "),
systemPrompt: agent.body,
model: agent.model,
adapter: agent.adapter,
cwd: agent.cwd ?? "",
timeout: agent.timeout,
permissionMode: agent.permissionMode,
effort: agent.effort ?? "",
selectedSkills: new Set<string>(agent.skills),
selectedMcpServers: new Set<string>(agent.mcpServers ?? []),
skillsBody: agent.skillsBody,
contextBody: agent.contextBody,
approvalRequired: agent.approvalRequired.join(", "),
memory: agent.memory,
memoryTokenBudget: agent.memoryTokenBudget,
reflectionEnabled: agent.reflection.enabled,
reflectionProposeSkills: agent.reflection.proposeSkills,
enabled: agent.enabled,
allowedCommands: agent.permissionRules.allow.join("\n"),
blockedCommands: agent.permissionRules.deny.join("\n"),
heartbeatEnabled: agent.heartbeatEnabled,
heartbeatSchedule: agent.heartbeatSchedule,
heartbeatBody: agent.heartbeatBody,
heartbeatNotify: agent.heartbeatNotify,
heartbeatChannel: agent.heartbeatChannel,
autoCompactThreshold: agent.autoCompactThreshold ?? 85,
wikiReferences: (agent.wikiReferences ?? []).map((r) => r.agent),
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Identity Section ───
const identitySection = form.createDiv({ cls: "af-create-section" });
const identityHeader = identitySection.createDiv({ cls: "af-create-section-header" });
const identityIcon = identityHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(identityIcon, "user");
identityHeader.createSpan({ text: "Identity" });
// Name (read-only for edit)
const nameRow = identitySection.createDiv({ cls: "af-form-row" });
nameRow.createDiv({ cls: "af-form-label", text: "Name" });
const nameInput = nameRow.createEl("input", {
cls: "af-form-input",
attr: { type: "text", value: agent.name, disabled: "true" },
});
nameInput.setCssStyles({ opacity: "0.6" });
this.createFormField(identitySection, "Description", "Monitors deployments and reports status", "", (v) => { state.description = v; }, agent.description ?? "");
const avatarRow = identitySection.createDiv({ cls: "af-form-row" });
avatarRow.createDiv({ cls: "af-form-label", text: "Avatar" });
const avatarPickerBtn = avatarRow.createEl("button", { cls: "af-avatar-picker-btn" });
const avatarPreview = avatarPickerBtn.createDiv({ cls: "af-avatar-picker-preview" });
this.renderAgentAvatar(avatarPreview, { ...agent, avatar: state.avatar ?? agent.avatar });
avatarPickerBtn.createSpan({ cls: "af-avatar-picker-label", text: state.avatar || agent.avatar || "Pick icon…" });
avatarPickerBtn.addEventListener("click", () => {
new IconPickerModal(this.app, state.avatar ?? agent.avatar, (iconName) => {
state.avatar = iconName;
avatarPreview.empty();
setIcon(avatarPreview, iconName);
avatarPickerBtn.querySelector(".af-avatar-picker-label")?.setText(iconName);
}).open();
});
this.createFormField(identitySection, "Tags", "devops, monitoring", "Comma-separated", (v) => { state.tags = v; }, agent.tags.join(", "));
// ─── Enabled Toggle ───
const enabledRow = identitySection.createDiv({ cls: "af-form-row" });
enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
const enabledToggle = enabledRow.createDiv({ cls: `af-agent-card-toggle${agent.enabled ? " on" : ""}` });
enabledToggle.onclick = () => {
const isOn = enabledToggle.hasClass("on");
enabledToggle.toggleClass("on", !isOn);
state.enabled = !isOn;
};
// ─── System Prompt Section ───
const promptSection = form.createDiv({ cls: "af-create-section" });
const promptHeader = promptSection.createDiv({ cls: "af-create-section-header" });
const promptIcon = promptHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(promptIcon, "message-square");
promptHeader.createSpan({ text: "System Prompt" });
const promptTextarea = promptSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "System prompt...", rows: "10" },
});
promptTextarea.value = agent.body;
promptTextarea.addEventListener("input", () => { state.systemPrompt = promptTextarea.value; });
// ─── Runtime Config Section ───
const configSection = form.createDiv({ cls: "af-create-section" });
const configHeader = configSection.createDiv({ cls: "af-create-section-header" });
const configIcon = configHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(configIcon, "settings");
configHeader.createSpan({ text: "Runtime Config" });
const configGrid = configSection.createDiv({ cls: "af-create-config-grid" });
// Adapter (before model)
const adapterRow = configGrid.createDiv({ cls: "af-form-row" });
adapterRow.createDiv({ cls: "af-form-label", text: "Adapter" });
const adapterSelect = adapterRow.createEl("select", { cls: "af-form-select" });
for (const [val, lbl, disabled] of ADAPTER_FORM_OPTIONS) {
const opt = adapterSelect.createEl("option", { text: lbl, attr: { value: val, ...(disabled ? { disabled: "true" } : {}) } });
if (val === agent.adapter || (isCodexAdapterValue(agent.adapter) && val === "codex")) opt.selected = true;
}
// Model
const modelRow = configGrid.createDiv({ cls: "af-form-row" });
const modelLabel = modelRow.createDiv({ cls: "af-form-label", text: "Model" });
this.addTooltip(
modelLabel,
`Aliases (opus/sonnet/haiku/opusplan) work on any backend. Choose Custom\u2026 for a pinned ID or Bedrock/Vertex/Foundry. Blank = use Settings default (${this.plugin.settings.defaultModel || "CLI default"}).`,
);
const modelFieldWrap = modelRow.createDiv({ cls: "af-form-field-wrap" });
const renderEditModelField = () => {
renderModelPicker(modelFieldWrap, {
value: state.model,
adapter: state.adapter,
onChange: (value) => { state.model = value; },
});
};
renderEditModelField();
let repopulateEditPermModeSelect: () => void = () => { /* assigned below */ };
adapterSelect.addEventListener("change", () => {
state.adapter = adapterSelect.value;
const otherAliases = isCodexAdapterValue(state.adapter) ? MODEL_ALIASES : CODEX_MODEL_ALIASES;
if (otherAliases.some((a) => a.value === state.model.trim())) {
state.model = "";
}
renderEditModelField();
state.permissionMode = permModeForAdapter(state.permissionMode, state.adapter);
repopulateEditPermModeSelect();
});
// Working Dir
const cwdRow = configGrid.createDiv({ cls: "af-form-row" });
cwdRow.createDiv({ cls: "af-form-label", text: "Working Dir" });
const cwdInput = cwdRow.createEl("input", {
cls: "af-form-input",
attr: { type: "text", placeholder: "Leave empty for vault root", value: agent.cwd ?? "" },
});
cwdInput.addEventListener("input", () => { state.cwd = cwdInput.value; });
// Timeout
const timeoutRow = configGrid.createDiv({ cls: "af-form-row" });
timeoutRow.createDiv({ cls: "af-form-label", text: "Timeout (sec)" });
const timeoutInput = timeoutRow.createEl("input", {
cls: "af-form-input af-form-input-sm",
attr: { type: "number", value: String(agent.timeout) },
});
timeoutInput.addEventListener("input", () => {
const n = parseInt(timeoutInput.value, 10);
if (!isNaN(n) && n > 0) state.timeout = n;
});
// Permission Mode (options depend on the selected adapter)
const permRow = configGrid.createDiv({ cls: "af-form-row" });
permRow.createDiv({ cls: "af-form-label", text: "Permission Mode" });
const permSelect = permRow.createEl("select", { cls: "af-form-select" });
const editPermDescEl = configGrid.createDiv({ cls: "af-form-hint", text: "" });
repopulateEditPermModeSelect = () => {
// Snap any value left over from the other adapter family to its
// nearest equivalent so the dropdown always reflects what gets saved.
state.permissionMode = permModeForAdapter(state.permissionMode, state.adapter);
const options = permModeOptionsFor(state.adapter);
permSelect.empty();
for (const [val, lbl] of options) {
const opt = permSelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === state.permissionMode) opt.selected = true;
}
editPermDescEl.textContent = options.find(([v]) => v === permSelect.value)?.[2] ?? "";
};
repopulateEditPermModeSelect();
permSelect.addEventListener("change", () => {
state.permissionMode = permSelect.value;
editPermDescEl.textContent = permModeOptionsFor(state.adapter).find(([v]) => v === permSelect.value)?.[2] ?? "";
});
// Effort Level
const editEffortRow = configGrid.createDiv({ cls: "af-form-row" });
editEffortRow.createDiv({ cls: "af-form-label", text: "Effort Level" });
const editEffortSelect = editEffortRow.createEl("select", { cls: "af-form-select" });
for (const [val, lbl] of [["", "Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"]] as const) {
const opt = editEffortSelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === (agent.effort ?? "")) opt.selected = true;
}
editEffortSelect.addEventListener("change", () => { state.effort = editEffortSelect.value; });
configGrid.createDiv({ cls: "af-form-hint", text: "Controls reasoning depth — low is fast, max is most thorough" });
// Auto-compact threshold
const editCompactRow = configGrid.createDiv({ cls: "af-form-row" });
const editCompactLabel = editCompactRow.createDiv({ cls: "af-form-label", text: "Auto-compact at" });
this.addTooltip(
editCompactLabel,
"Percent of context window at which the chat auto-invokes /compact before the next message. 85% is a good default. Set 0 to disable.",
);
const editCompactInput = editCompactRow.createEl("input", {
cls: "af-form-input af-form-input-sm",
attr: { type: "number", min: "0", max: "100", value: String(state.autoCompactThreshold) },
});
editCompactInput.addEventListener("input", () => {
const n = parseInt(editCompactInput.value, 10);
if (!isNaN(n) && n >= 0 && n <= 100) state.autoCompactThreshold = n;
});
configGrid.createDiv({ cls: "af-form-hint", text: "0 disables auto-compact" });
// Wiki references
{
const wikiKeepers = this.plugin.runtime.getSnapshot().agents.filter((a) => a.wikiKeeper !== undefined);
if (wikiKeepers.length > 0) {
const wrRow = configGrid.createDiv({ cls: "af-form-row af-form-row-toggle" });
const wrLabel = wrRow.createDiv({ cls: "af-form-label", text: "Wiki access" });
this.addTooltip(
wrLabel,
"Lets this agent read + cite from the selected Wiki Keeper scopes (requires the wiki-query skill).",
);
const wrWrap = wrRow.createDiv({ cls: "af-form-field-wrap" });
for (const wk of wikiKeepers) {
const label = wrWrap.createEl("label", { cls: "af-form-checkbox-row" });
const cb = label.createEl("input", { attr: { type: "checkbox" } }) as HTMLInputElement;
if (state.wikiReferences.includes(wk.name)) cb.checked = true;
label.createSpan({ text: ` ${wk.name}`, cls: "af-form-checkbox-label" });
cb.addEventListener("change", () => {
if (cb.checked) {
if (!state.wikiReferences.includes(wk.name)) state.wikiReferences.push(wk.name);
} else {
state.wikiReferences = state.wikiReferences.filter((n) => n !== wk.name);
}
});
}
}
}
// ─── Heartbeat Section ───
if (agent.isFolder) {
const heartbeatSection = form.createDiv({ cls: "af-create-section" });
const heartbeatHeader = heartbeatSection.createDiv({ cls: "af-create-section-header" });
const heartbeatIcon = heartbeatHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(heartbeatIcon, "heart-pulse");
const heartbeatHeaderLabel = heartbeatHeader.createSpan({ text: "Heartbeat" });
this.addTooltip(heartbeatHeaderLabel, "Autonomous periodic run — what the agent does when no one is asking");
const hbEnabledRow = heartbeatSection.createDiv({ cls: "af-form-row af-form-row-toggle" });
hbEnabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
const hbEnabledToggle = hbEnabledRow.createDiv({ cls: `af-agent-card-toggle${state.heartbeatEnabled ? " on" : ""}` });
const hbBody = heartbeatSection.createDiv();
hbBody.setCssStyles({ display: state.heartbeatEnabled ? "" : "none" });
hbEnabledToggle.onclick = () => {
const isOn = hbEnabledToggle.hasClass("on");
hbEnabledToggle.toggleClass("on", !isOn);
state.heartbeatEnabled = !isOn;
hbBody.setCssStyles({ display: !isOn ? "" : "none" });
};
this.renderHeartbeatSchedule(hbBody, state);
const hbNotifyRow = hbBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
const hbNotifyLabel = hbNotifyRow.createDiv({ cls: "af-form-label" });
hbNotifyLabel.setText("Notify");
this.addTooltip(hbNotifyLabel, "Show an Obsidian notice when the heartbeat completes");
const hbNotifyToggle = hbNotifyRow.createDiv({ cls: `af-agent-card-toggle${state.heartbeatNotify ? " on" : ""}` });
hbNotifyToggle.onclick = () => {
const isOn = hbNotifyToggle.hasClass("on");
hbNotifyToggle.toggleClass("on", !isOn);
state.heartbeatNotify = !isOn;
};
const editSnapshot = this.plugin.runtime.getSnapshot();
const hbChannelRow = hbBody.createDiv({ cls: "af-form-row" });
const hbChannelLabel = hbChannelRow.createDiv({ cls: "af-form-label" });
hbChannelLabel.setText("Post to channel");
this.addTooltip(hbChannelLabel, "Heartbeat results are posted to this Slack channel when the run completes");
const hbChannelSelect = hbChannelRow.createEl("select", { cls: "af-form-select" });
hbChannelSelect.createEl("option", { text: "(none)", attr: { value: "" } });
for (const ch of editSnapshot.channels) {
const opt = hbChannelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } });
if (ch.name === state.heartbeatChannel) opt.selected = true;
}
hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; });
const hbInstructionLabel = hbBody.createDiv({ cls: "af-form-label" });
hbInstructionLabel.setCssStyles({ width: "auto" });
hbInstructionLabel.setCssStyles({ marginTop: "12px" });
hbInstructionLabel.setText("Instruction");
this.addTooltip(hbInstructionLabel, "What the agent does on each heartbeat. Also used by the \"Run Now\" button.");
const hbTextarea = hbBody.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "Check status, scan for issues, report findings...", rows: "8" },
});
hbTextarea.value = state.heartbeatBody;
hbTextarea.addEventListener("input", () => { state.heartbeatBody = hbTextarea.value; });
}
// ─── Skills Section ───
const skillsSection = form.createDiv({ cls: "af-create-section" });
const skillsHeader = skillsSection.createDiv({ cls: "af-create-section-header" });
const skillsIcon = skillsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(skillsIcon, "puzzle");
skillsHeader.createSpan({ text: "Skills" });
const snapshot = this.plugin.runtime.getSnapshot();
if (snapshot.skills.length > 0) {
skillsSection.createDiv({ cls: "af-form-sublabel", text: "Shared Skills" });
const skillsGrid = skillsSection.createDiv({ cls: "af-create-skills-grid" });
for (const skill of snapshot.skills) {
const item = skillsGrid.createDiv({ cls: "af-create-skill-item" });
const cb = item.createEl("input", { cls: "af-form-toggle", attr: { type: "checkbox" } });
cb.checked = state.selectedSkills.has(skill.name);
cb.addEventListener("change", () => {
if (cb.checked) state.selectedSkills.add(skill.name);
else state.selectedSkills.delete(skill.name);
});
const lbl = item.createDiv({ cls: "af-create-skill-label" });
lbl.createSpan({ cls: "af-create-skill-name", text: skill.name });
if (skill.description) {
lbl.createSpan({ cls: "af-create-skill-desc", text: `${skill.description}` });
}
}
}
const agentSkillsLabel = skillsSection.createDiv({ cls: "af-form-sublabel" });
agentSkillsLabel.setText("Agent-specific skills");
this.addTooltip(agentSkillsLabel, "Custom skills/instructions only for this agent, not shared with others");
const skillsTextarea = skillsSection.createEl("textarea", {
cls: "af-create-textarea",
attr: { placeholder: "Custom skills/instructions for this agent...", rows: "4" },
});
skillsTextarea.value = agent.skillsBody;
skillsTextarea.addEventListener("input", () => { state.skillsBody = skillsTextarea.value; });
// ─── MCP Servers Section ───
const mcpSection = form.createDiv({ cls: "af-create-section" });
const mcpHeader = mcpSection.createDiv({ cls: "af-create-section-header" });
const mcpIcon = mcpHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(mcpIcon, "plug");
const editMcpHeaderLabel = mcpHeader.createSpan({ text: "MCP Servers" });
this.addTooltip(editMcpHeaderLabel, "Grant agent access to MCP servers");
this.renderAgentMcpPicker(mcpSection, state.selectedMcpServers);
// ─── Context Section ───
const contextSection = form.createDiv({ cls: "af-create-section" });
const contextHeader = contextSection.createDiv({ cls: "af-create-section-header" });
const contextIcon = contextHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(contextIcon, "file-text");
const contextHeaderLabel = contextHeader.createSpan({ text: "Context" });
this.addTooltip(contextHeaderLabel, "Project-specific context included in every run");
const contextTextarea = contextSection.createEl("textarea", {
cls: "af-create-textarea",
attr: { placeholder: "Background info, repo structure, conventions...", rows: "4" },
});
contextTextarea.value = agent.contextBody;
contextTextarea.addEventListener("input", () => { state.contextBody = contextTextarea.value; });
// ─── Permissions Section ───
const permsSection = form.createDiv({ cls: "af-create-section" });
const permsHeader = permsSection.createDiv({ cls: "af-create-section-header" });
const permsIcon = permsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(permsIcon, "shield-check");
permsHeader.createSpan({ text: "Permissions" });
this.createFormField(permsSection, "Approval required", "git_push, file_delete", "Comma-separated tool names", (v) => { state.approvalRequired = v; }, agent.approvalRequired.join(", "));
const editAllowRow = permsSection.createDiv({ cls: "af-form-row" });
editAllowRow.createDiv({ cls: "af-form-label", text: "Allowed Commands" });
const editAllowTextarea = editAllowRow.createEl("textarea", {
cls: "af-create-textarea",
attr: { placeholder: "Bash(curl *)\nBash(python3 *)\nRead\nEdit\nWrite", rows: "4" },
});
editAllowTextarea.value = agent.permissionRules.allow.join("\n");
editAllowTextarea.addEventListener("input", () => { state.allowedCommands = editAllowTextarea.value; });
const editDenyRow = permsSection.createDiv({ cls: "af-form-row" });
editDenyRow.createDiv({ cls: "af-form-label", text: "Blocked Commands" });
const editDenyTextarea = editDenyRow.createEl("textarea", {
cls: "af-create-textarea",
attr: { placeholder: "Bash(git push *)\nBash(rm -rf *)\nBash(sudo *)", rows: "4" },
});
editDenyTextarea.value = agent.permissionRules.deny.join("\n");
editDenyTextarea.addEventListener("input", () => { state.blockedCommands = editDenyTextarea.value; });
permsSection.createDiv({
cls: "af-form-hint",
text:
"On Codex agents these become execpolicy command rules — only Bash(cmd args *) " +
"prefixes are enforced; tool-name rules (Read/Write) and mid-pattern wildcards are " +
"ignored, and file/network access is governed by Permission Mode (the sandbox).",
});
const memoryRow = permsSection.createDiv({ cls: "af-form-row" });
memoryRow.createDiv({ cls: "af-form-label", text: "Memory enabled" });
const memoryToggle = memoryRow.createDiv({ cls: `af-agent-card-toggle${agent.memory ? " on" : ""}` });
memoryToggle.onclick = () => {
const isOn = memoryToggle.hasClass("on");
memoryToggle.toggleClass("on", !isOn);
state.memory = !isOn;
};
const budgetRow = permsSection.createDiv({ cls: "af-form-row" });
budgetRow.createDiv({ cls: "af-form-label", text: "Memory token budget" });
const budgetInput = budgetRow.createEl("input", {
cls: "af-create-input",
attr: { type: "number", min: "200", step: "100" },
});
budgetInput.value = String(state.memoryTokenBudget);
budgetInput.addEventListener("input", () => {
const v = parseInt(budgetInput.value, 10);
if (Number.isFinite(v)) state.memoryTokenBudget = v;
});
const reflectRow = permsSection.createDiv({ cls: "af-form-row" });
reflectRow.createDiv({ cls: "af-form-label", text: "Nightly reflection" });
const reflectToggle = reflectRow.createDiv({
cls: `af-agent-card-toggle${agent.reflection.enabled ? " on" : ""}`,
});
reflectToggle.onclick = () => {
const isOn = reflectToggle.hasClass("on");
reflectToggle.toggleClass("on", !isOn);
state.reflectionEnabled = !isOn;
};
const proposeRow = permsSection.createDiv({ cls: "af-form-row" });
proposeRow.createDiv({ cls: "af-form-label", text: "Propose skills from memory" });
const proposeToggle = proposeRow.createDiv({
cls: `af-agent-card-toggle${agent.reflection.proposeSkills ? " on" : ""}`,
});
proposeToggle.onclick = () => {
const isOn = proposeToggle.hasClass("on");
proposeToggle.toggleClass("on", !isOn);
state.reflectionProposeSkills = !isOn;
};
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" });
createIcon(deleteBtn, "trash-2", "af-btn-icon");
deleteBtn.appendText(" Delete");
deleteBtn.onclick = () => void this.plugin.deleteAgent(agent.name);
footer.createDiv({ cls: "af-toolbar-spacer" });
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => this.navigate("agent-detail", agent.name);
const saveBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(saveBtn, "check", "af-btn-icon");
saveBtn.appendText(" Save Changes");
saveBtn.onclick = async () => {
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
try {
const parseLines = (s: string) => splitLines(s).map((l) => l.trim()).filter(Boolean);
await this.plugin.repository.updateAgent(agent.name, {
description: state.description.trim(),
avatar: state.avatar.trim(),
tags: parseTags(state.tags),
systemPrompt: state.systemPrompt.trim(),
model: state.model.trim() || "default",
adapter: state.adapter,
cwd: state.cwd.trim(),
timeout: state.timeout,
permissionMode: state.permissionMode,
effort: state.effort || undefined,
approvalRequired: parseTags(state.approvalRequired),
memory: state.memory,
memoryTokenBudget: state.memoryTokenBudget,
reflectionEnabled: state.reflectionEnabled,
reflectionProposeSkills: state.reflectionProposeSkills,
skills: Array.from(state.selectedSkills),
mcpServers: Array.from(state.selectedMcpServers),
skillsBody: state.skillsBody.trim(),
contextBody: state.contextBody.trim(),
enabled: state.enabled,
permissionRules: {
allow: parseLines(state.allowedCommands),
deny: parseLines(state.blockedCommands),
},
autoCompactThreshold: state.autoCompactThreshold,
wikiReferences: state.wikiReferences,
});
// Persist heartbeat separately (lives in HEARTBEAT.md, not agent.md/config.md)
if (agent.isFolder) {
await this.plugin.repository.updateHeartbeat(agent.name, {
enabled: state.heartbeatEnabled,
schedule: state.heartbeatSchedule.trim(),
notify: state.heartbeatNotify,
channel: state.heartbeatChannel,
body: state.heartbeatBody.trim(),
});
}
new Notice(`Agent "${agent.name}" updated.`);
await this.plugin.refreshFromVault();
this.navigate("agent-detail", agent.name);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to update agent: ${msg}`);
}
};
}
// ═══════════════════════════════════════════════════════
// Create Task Page
// ═══════════════════════════════════════════════════════
private renderCreateTaskPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-create-agent-page" });
const snapshot = this.plugin.runtime.getSnapshot();
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(avatar, "plus");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Task" });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Configure a new task for your fleet" });
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
// Form state
const state = {
title: "",
agent: snapshot.agents[0]?.name ?? "",
priority: "medium",
tags: "",
body: "",
scheduleEnabled: false,
/** "recurring" (cron) or "once" (run_at). Only consulted when
* scheduleEnabled is true; otherwise the task is "immediate". */
scheduleMode: "recurring" as "recurring" | "once",
schedule: "0 9 * * *",
runAt: "",
type: "immediate" as string,
enabled: true,
catchUp: true,
effort: "",
model: "",
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Task Details Section ───
const detailsSection = form.createDiv({ cls: "af-create-section" });
const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" });
const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(detailsIcon, "file-text");
detailsHeader.createSpan({ text: "Task Details" });
this.createFormField(detailsSection, "Title", "Daily status report", "Used as the task identifier", (v) => { state.title = v; });
// Agent dropdown
const agentRow = detailsSection.createDiv({ cls: "af-form-row" });
agentRow.createDiv({ cls: "af-form-label", text: "Agent" });
const agentSelect = agentRow.createEl("select", { cls: "af-form-select" });
for (const a of snapshot.agents) {
agentSelect.createEl("option", { text: a.name, attr: { value: a.name } });
}
agentSelect.addEventListener("change", () => { state.agent = agentSelect.value; });
// Priority dropdown
const priorityRow = detailsSection.createDiv({ cls: "af-form-row" });
priorityRow.createDiv({ cls: "af-form-label", text: "Priority" });
const prioritySelect = priorityRow.createEl("select", { cls: "af-form-select" });
const priorityOptions: Array<[string, string]> = [
["low", "Low"], ["medium", "Medium"], ["high", "High"], ["critical", "Critical"],
];
for (const [val, lbl] of priorityOptions) {
const opt = prioritySelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === "medium") opt.selected = true;
}
prioritySelect.addEventListener("change", () => { state.priority = prioritySelect.value; });
// Tags
this.createFormField(detailsSection, "Tags", "monitoring, devops", "Comma-separated", (v) => { state.tags = v; });
// ─── Instructions Section ───
const instructionsSection = form.createDiv({ cls: "af-create-section" });
const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" });
const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(instructionsIcon, "message-square");
instructionsHeader.createSpan({ text: "Instructions" });
const instructionsTextarea = instructionsSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "Describe what the agent should do...", rows: "10" },
});
instructionsTextarea.addEventListener("input", () => { state.body = instructionsTextarea.value; });
// ─── Schedule Section ───
const scheduleSection = form.createDiv({ cls: "af-create-section" });
const scheduleHeader = scheduleSection.createDiv({ cls: "af-create-section-header" });
const scheduleIcon = scheduleHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(scheduleIcon, "clock");
scheduleHeader.createSpan({ text: "Schedule" });
// Enable schedule toggle
const scheduleToggleRow = scheduleSection.createDiv({ cls: "af-form-row af-form-row-toggle" });
scheduleToggleRow.createDiv({ cls: "af-form-label", text: "Enable schedule" });
const scheduleToggle = scheduleToggleRow.createDiv({ cls: "af-agent-card-toggle" });
const scheduleBody = scheduleSection.createDiv({ cls: "af-schedule-body" });
scheduleBody.setCssStyles({ display: "none" });
scheduleToggle.onclick = () => {
const isOn = scheduleToggle.hasClass("on");
scheduleToggle.toggleClass("on", !isOn);
state.scheduleEnabled = !isOn;
scheduleBody.setCssStyles({ display: !isOn ? "" : "none" });
if (!isOn) {
state.type = state.scheduleMode === "once" ? "once" : "recurring";
} else {
state.type = "immediate";
}
};
// Schedule mode selector (recurring vs one-time)
const modeRow = scheduleBody.createDiv({ cls: "af-form-row" });
modeRow.createDiv({ cls: "af-form-label", text: "Mode" });
const modeSelect = modeRow.createEl("select", { cls: "af-form-select" });
for (const [val, lbl] of [["recurring", "Recurring"], ["once", "One-time"]] as const) {
modeSelect.createEl("option", { text: lbl, attr: { value: val } });
}
const cronHost = scheduleBody.createDiv();
const onceHost = scheduleBody.createDiv();
onceHost.setCssStyles({ display: "none" });
this.renderInlineSchedule(cronHost, state);
const onceRow = onceHost.createDiv({ cls: "af-form-row" });
onceRow.createDiv({ cls: "af-form-label", text: "Run at" });
const onceInput = onceRow.createEl("input", {
cls: "af-form-input",
attr: { type: "datetime-local", value: this.toDatetimeLocal(new Date(Date.now() + 3600_000)) },
});
// Seed the initial value — the task won't persist blank runAt
state.runAt = new Date(onceInput.value).toISOString();
onceInput.addEventListener("input", () => {
state.runAt = onceInput.value ? new Date(onceInput.value).toISOString() : "";
});
modeSelect.addEventListener("change", () => {
state.scheduleMode = modeSelect.value as "recurring" | "once";
cronHost.setCssStyles({ display: state.scheduleMode === "recurring" ? "" : "none" });
onceHost.setCssStyles({ display: state.scheduleMode === "once" ? "" : "none" });
if (state.scheduleEnabled) state.type = state.scheduleMode === "once" ? "once" : "recurring";
});
// Enabled toggle (only when schedule is on)
const enabledRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
const enabledToggle = enabledRow.createDiv({ cls: "af-agent-card-toggle on" });
enabledToggle.onclick = () => {
const isOn = enabledToggle.hasClass("on");
enabledToggle.toggleClass("on", !isOn);
state.enabled = !isOn;
};
// Catch up missed runs toggle
const catchUpRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
const catchUpLabel = catchUpRow.createDiv({ cls: "af-form-label" });
catchUpLabel.setText("Catch up if missed");
const catchUpToggle = catchUpRow.createDiv({ cls: `af-agent-card-toggle${state.catchUp ? " on" : ""}` });
catchUpToggle.onclick = () => {
const isOn = catchUpToggle.hasClass("on");
catchUpToggle.toggleClass("on", !isOn);
state.catchUp = !isOn;
};
// ─── Execution Section ───
// Model & effort are per-run controls, not scheduling knobs — they apply
// whether or not the task has a schedule, so they live in their own section.
const execSection = form.createDiv({ cls: "af-create-section" });
const execHeader = execSection.createDiv({ cls: "af-create-section-header" });
const execIcon = execHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(execIcon, "gauge");
execHeader.createSpan({ text: "Execution" });
// Model override (per-task)
const taskModelRow = execSection.createDiv({ cls: "af-form-row" });
const taskModelLabel = taskModelRow.createDiv({ cls: "af-form-label", text: "Model" });
const taskModelFieldWrap = taskModelRow.createDiv({ cls: "af-form-field-wrap" });
const renderCreateTaskModelPicker = (agentName: string) => {
taskModelFieldWrap.empty();
const selAgent = snapshot.agents.find((a) => a.name === agentName);
renderModelPicker(taskModelFieldWrap, {
value: state.model,
adapter: selAgent?.adapter,
onChange: (value) => { state.model = value; },
allowInherit: true,
inheritPlaceholder: selAgent
? `Inherit from ${selAgent.name}${selAgent.model ? ` (${selAgent.model})` : ""}`
: "Inherit from agent",
});
};
renderCreateTaskModelPicker(state.agent);
agentSelect.addEventListener("change", () => renderCreateTaskModelPicker(agentSelect.value));
this.addTooltip(
taskModelLabel,
"Override the agent\u2019s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.",
);
// Effort level
const taskEffortRow = execSection.createDiv({ cls: "af-form-row" });
const taskEffortLabel = taskEffortRow.createDiv({ cls: "af-form-label", text: "Effort" });
const taskEffortSelect = taskEffortRow.createEl("select", { cls: "af-form-select" });
for (const [val, lbl] of [["", "Agent Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"]] as const) {
const opt = taskEffortSelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === state.effort) opt.selected = true;
}
taskEffortSelect.addEventListener("change", () => { state.effort = taskEffortSelect.value; });
this.addTooltip(
taskEffortLabel,
"Overrides the agent\u2019s effort level for this task. Higher effort = more thinking tokens spent.",
);
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => this.navigate("kanban");
const createBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(createBtn, "plus", "af-btn-icon");
createBtn.appendText(" Create Task");
createBtn.onclick = async () => {
const title = state.title.trim();
if (!title) {
new Notice("Task title is required.");
return;
}
const taskId = slugify(title);
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
const effectiveType = state.scheduleEnabled
? state.scheduleMode === "once"
? "once"
: "recurring"
: "immediate";
const frontmatter: Record<string, unknown> = {
task_id: taskId,
agent: state.agent,
type: effectiveType,
priority: state.priority,
enabled: state.enabled,
created: this.toLocalISO(new Date()),
run_count: 0,
catch_up: state.catchUp,
effort: state.effort || undefined,
model: state.model || undefined,
tags: parseTags(state.tags),
};
if (effectiveType === "recurring") {
frontmatter.schedule = state.schedule.trim() || "0 9 * * *";
} else if (effectiveType === "once") {
if (!state.runAt) {
new Notice("Pick a date/time for the one-time run.");
return;
}
frontmatter.run_at = state.runAt;
}
try {
const path = await this.plugin.repository.getAvailablePath(
this.plugin.repository.getSubfolder("tasks"),
taskId,
);
await this.plugin.app.vault.create(
path,
stringifyMarkdownWithFrontmatter(frontmatter, state.body.trim() || "Describe the task here."),
);
new Notice(`Task "${taskId}" created.`);
await this.plugin.refreshFromVault();
this.navigate("task-detail", taskId);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to create task: ${msg}`);
}
};
}
private toLocalISO(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
/** Format a Date as the value string accepted by `<input type="datetime-local">`:
* `YYYY-MM-DDTHH:mm`, local timezone, no seconds. */
private toDatetimeLocal(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
// ═══════════════════════════════════════════════════════
// Edit Task Page
// ═══════════════════════════════════════════════════════
private renderEditTaskPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-create-agent-page" });
const taskId = this.detailContext;
if (!taskId) {
this.renderEmptyState(page, "circle-dot", "No task selected", "");
return;
}
const task = this.plugin.runtime.getSnapshot().tasks.find((t) => t.taskId === taskId);
if (!task) {
this.renderEmptyState(page, "circle-dot", "Task not found", `Task "${taskId}" was not found`);
return;
}
const snapshot = this.plugin.runtime.getSnapshot();
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const taskIcon = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(taskIcon, "edit");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Task: ${task.taskId}` });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Modify task configuration" });
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
const hasSchedule = !!(task.schedule || task.runAt);
// Form state pre-filled
const state = {
agent: task.agent,
type: task.type as string,
priority: task.priority,
schedule: task.schedule ?? "0 9 * * *",
runAt: task.runAt ?? "",
scheduleEnabled: hasSchedule,
scheduleMode: (task.type === "once" ? "once" : "recurring") as "recurring" | "once",
enabled: task.enabled,
catchUp: task.catchUp,
effort: task.effort ?? "",
model: task.model ?? "",
tags: task.tags.join(", "),
body: task.body,
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Task Details Section ───
const detailsSection = form.createDiv({ cls: "af-create-section" });
const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" });
const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(detailsIcon, "file-text");
detailsHeader.createSpan({ text: "Task Details" });
// Title (read-only)
const nameRow = detailsSection.createDiv({ cls: "af-form-row" });
nameRow.createDiv({ cls: "af-form-label", text: "Title" });
const nameInput = nameRow.createEl("input", {
cls: "af-form-input",
attr: { type: "text", value: task.taskId, disabled: "true" },
});
nameInput.setCssStyles({ opacity: "0.6" });
// Agent dropdown
const agentRow = detailsSection.createDiv({ cls: "af-form-row" });
agentRow.createDiv({ cls: "af-form-label", text: "Agent" });
const agentSelect = agentRow.createEl("select", { cls: "af-form-select" });
for (const a of snapshot.agents) {
const opt = agentSelect.createEl("option", { text: a.name, attr: { value: a.name } });
if (a.name === task.agent) opt.selected = true;
}
agentSelect.addEventListener("change", () => { state.agent = agentSelect.value; });
// Priority dropdown
const priorityRow = detailsSection.createDiv({ cls: "af-form-row" });
priorityRow.createDiv({ cls: "af-form-label", text: "Priority" });
const prioritySelect = priorityRow.createEl("select", { cls: "af-form-select" });
const priorityOptions: Array<[string, string]> = [
["low", "Low"], ["medium", "Medium"], ["high", "High"], ["critical", "Critical"],
];
for (const [val, lbl] of priorityOptions) {
const opt = prioritySelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === task.priority) opt.selected = true;
}
prioritySelect.addEventListener("change", () => { state.priority = prioritySelect.value as typeof state.priority; });
// Tags
this.createFormField(detailsSection, "Tags", "monitoring, critical", "Comma-separated", (v) => { state.tags = v; }, task.tags.join(", "));
// ─── Instructions Section ───
const instructionsSection = form.createDiv({ cls: "af-create-section" });
const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" });
const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(instructionsIcon, "message-square");
instructionsHeader.createSpan({ text: "Instructions" });
const instructionsTextarea = instructionsSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "Describe what the agent should do...", rows: "10" },
});
instructionsTextarea.value = task.body;
instructionsTextarea.addEventListener("input", () => { state.body = instructionsTextarea.value; });
// ─── Schedule Section ───
const scheduleSection = form.createDiv({ cls: "af-create-section" });
const scheduleHeader = scheduleSection.createDiv({ cls: "af-create-section-header" });
const scheduleIcon = scheduleHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(scheduleIcon, "clock");
scheduleHeader.createSpan({ text: "Schedule" });
// Enable schedule toggle
const scheduleToggleRow = scheduleSection.createDiv({ cls: "af-form-row af-form-row-toggle" });
scheduleToggleRow.createDiv({ cls: "af-form-label", text: "Enable schedule" });
const scheduleToggle = scheduleToggleRow.createDiv({ cls: `af-agent-card-toggle${hasSchedule ? " on" : ""}` });
const scheduleBody = scheduleSection.createDiv({ cls: "af-schedule-body" });
scheduleBody.setCssStyles({ display: hasSchedule ? "" : "none" });
scheduleToggle.onclick = () => {
const isOn = scheduleToggle.hasClass("on");
scheduleToggle.toggleClass("on", !isOn);
state.scheduleEnabled = !isOn;
scheduleBody.setCssStyles({ display: !isOn ? "" : "none" });
if (!isOn) {
state.type = state.scheduleMode === "once" ? "once" : "recurring";
} else {
state.type = "immediate";
}
};
// Schedule mode selector
const editModeRow = scheduleBody.createDiv({ cls: "af-form-row" });
editModeRow.createDiv({ cls: "af-form-label", text: "Mode" });
const editModeSelect = editModeRow.createEl("select", { cls: "af-form-select" });
for (const [val, lbl] of [["recurring", "Recurring"], ["once", "One-time"]] as const) {
const opt = editModeSelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === state.scheduleMode) opt.selected = true;
}
const editCronHost = scheduleBody.createDiv();
const editOnceHost = scheduleBody.createDiv();
editCronHost.setCssStyles({ display: state.scheduleMode === "recurring" ? "" : "none" });
editOnceHost.setCssStyles({ display: state.scheduleMode === "once" ? "" : "none" });
this.renderInlineSchedule(editCronHost, state);
const editOnceRow = editOnceHost.createDiv({ cls: "af-form-row" });
editOnceRow.createDiv({ cls: "af-form-label", text: "Run at" });
const prefillOnce = state.runAt
? this.toDatetimeLocal(new Date(state.runAt))
: this.toDatetimeLocal(new Date(Date.now() + 3600_000));
const editOnceInput = editOnceRow.createEl("input", {
cls: "af-form-input",
attr: { type: "datetime-local", value: prefillOnce },
});
if (!state.runAt) state.runAt = new Date(editOnceInput.value).toISOString();
editOnceInput.addEventListener("input", () => {
state.runAt = editOnceInput.value ? new Date(editOnceInput.value).toISOString() : "";
});
editModeSelect.addEventListener("change", () => {
state.scheduleMode = editModeSelect.value as "recurring" | "once";
editCronHost.setCssStyles({ display: state.scheduleMode === "recurring" ? "" : "none" });
editOnceHost.setCssStyles({ display: state.scheduleMode === "once" ? "" : "none" });
if (state.scheduleEnabled) state.type = state.scheduleMode === "once" ? "once" : "recurring";
});
// Enabled toggle (whether the schedule is active)
const enabledRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
const enabledToggle = enabledRow.createDiv({ cls: `af-agent-card-toggle${task.enabled ? " on" : ""}` });
enabledToggle.onclick = () => {
const isOn = enabledToggle.hasClass("on");
enabledToggle.toggleClass("on", !isOn);
state.enabled = !isOn;
};
// Catch up missed runs toggle
const catchUpRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
const catchUpLabel = catchUpRow.createDiv({ cls: "af-form-label" });
catchUpLabel.setText("Catch up if missed");
const catchUpToggle = catchUpRow.createDiv({ cls: `af-agent-card-toggle${state.catchUp ? " on" : ""}` });
catchUpToggle.onclick = () => {
const isOn = catchUpToggle.hasClass("on");
catchUpToggle.toggleClass("on", !isOn);
state.catchUp = !isOn;
};
// ─── Execution Section ───
const execSection = form.createDiv({ cls: "af-create-section" });
const execHeader = execSection.createDiv({ cls: "af-create-section-header" });
const execIcon = execHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(execIcon, "gauge");
execHeader.createSpan({ text: "Execution" });
// Effort level
const effortRow = execSection.createDiv({ cls: "af-form-row" });
const effortLabel = effortRow.createDiv({ cls: "af-form-label", text: "Effort" });
const effortSelect = effortRow.createEl("select", { cls: "af-form-select" });
const effortOptions: Array<[string, string]> = [
["", "Agent Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"],
];
for (const [val, lbl] of effortOptions) {
const opt = effortSelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === state.effort) opt.selected = true;
}
effortSelect.addEventListener("change", () => { state.effort = effortSelect.value; });
this.addTooltip(
effortLabel,
"Overrides the agent\u2019s effort level for this task. Higher effort = more thinking tokens spent.",
);
// Model override
const modelRowEdit = execSection.createDiv({ cls: "af-form-row" });
const modelLabelEdit = modelRowEdit.createDiv({ cls: "af-form-label", text: "Model" });
const modelFieldWrapEdit = modelRowEdit.createDiv({ cls: "af-form-field-wrap" });
const renderTaskModelPicker = (agentName: string) => {
modelFieldWrapEdit.empty();
const selAgent = snapshot.agents.find((a) => a.name === agentName);
renderModelPicker(modelFieldWrapEdit, {
value: state.model,
adapter: selAgent?.adapter,
onChange: (value) => { state.model = value; },
allowInherit: true,
inheritPlaceholder: selAgent
? `Inherit from ${selAgent.name}${selAgent.model ? ` (${selAgent.model})` : ""}`
: "Inherit from agent",
});
};
renderTaskModelPicker(state.agent);
agentSelect.addEventListener("change", () => renderTaskModelPicker(agentSelect.value));
this.addTooltip(
modelLabelEdit,
"Override the agent\u2019s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.",
);
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" });
createIcon(deleteBtn, "trash-2", "af-btn-icon");
deleteBtn.appendText(" Delete");
deleteBtn.onclick = async () => {
await this.plugin.repository.deleteTask(task.taskId);
new Notice(`Task "${task.taskId}" deleted.`);
await new Promise((r) => window.setTimeout(r, 200));
await this.plugin.refreshFromVault();
this.navigate("kanban");
};
footer.createDiv({ cls: "af-toolbar-spacer" });
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => this.navigate("task-detail", task.taskId);
const saveBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(saveBtn, "check", "af-btn-icon");
saveBtn.appendText(" Save Changes");
saveBtn.onclick = async () => {
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
const effectiveType = state.scheduleEnabled
? state.scheduleMode === "once"
? "once"
: "recurring"
: "immediate";
if (effectiveType === "once" && !state.runAt) {
new Notice("Pick a date/time for the one-time run.");
return;
}
try {
await this.plugin.repository.updateTask(task.taskId, {
agent: state.agent,
type: effectiveType,
priority: state.priority,
schedule: effectiveType === "recurring" ? state.schedule.trim() : "",
runAt: effectiveType === "once" ? state.runAt : "",
enabled: state.enabled,
catch_up: state.catchUp,
effort: state.effort || undefined,
model: state.model || "",
tags: parseTags(state.tags),
body: state.body.trim(),
});
new Notice(`Task "${task.taskId}" updated.`);
await this.plugin.refreshFromVault();
this.navigate("task-detail", task.taskId);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to update task: ${msg}`);
}
};
}
// ═══════════════════════════════════════════════════════
// Edit Skill Page
// ═══════════════════════════════════════════════════════
private renderEditSkillPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-create-agent-page" });
const skillName = this.detailContext;
if (!skillName) {
this.renderEmptyState(page, "puzzle", "No skill selected", "");
return;
}
const skill = this.plugin.runtime.getSnapshot().skills.find((s) => s.name === skillName);
if (!skill) {
this.renderEmptyState(page, "puzzle", "Skill not found", `Skill "${skillName}" was not found`);
return;
}
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const avatarEl = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(avatarEl, "edit");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Skill: ${skill.name}` });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Modify skill definition" });
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
// Form state pre-filled
const state = {
description: skill.description ?? "",
tags: skill.tags.join(", "),
body: skill.body,
toolsBody: skill.toolsBody,
referencesBody: skill.referencesBody,
examplesBody: skill.examplesBody,
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Identity Section ───
const identitySection = form.createDiv({ cls: "af-create-section" });
const identityHeader = identitySection.createDiv({ cls: "af-create-section-header" });
const identityIcon = identityHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(identityIcon, "puzzle");
identityHeader.createSpan({ text: "Identity" });
// Name (read-only)
const nameRow = identitySection.createDiv({ cls: "af-form-row" });
nameRow.createDiv({ cls: "af-form-label", text: "Name" });
const nameInput = nameRow.createEl("input", {
cls: "af-form-input",
attr: { type: "text", value: skill.name, disabled: "true" },
});
nameInput.setCssStyles({ opacity: "0.6" });
this.createFormField(identitySection, "Description", "Manage tasks and projects via CLI", "", (v) => { state.description = v; }, skill.description ?? "");
this.createFormField(identitySection, "Tags", "productivity, tasks", "Comma-separated", (v) => { state.tags = v; }, skill.tags.join(", "));
// ─── Core Instructions Section ───
const instructionsSection = form.createDiv({ cls: "af-create-section" });
const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" });
const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(instructionsIcon, "file-text");
instructionsHeader.createSpan({ text: "Core Instructions" });
const bodyTextarea = instructionsSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "Skill instructions...", rows: "10" },
});
bodyTextarea.value = skill.body;
bodyTextarea.addEventListener("input", () => { state.body = bodyTextarea.value; });
// ─── Tools Section ───
const toolsSection = form.createDiv({ cls: "af-create-section" });
const toolsHeader = toolsSection.createDiv({ cls: "af-create-section-header" });
const toolsIcon = toolsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(toolsIcon, "wrench");
const editToolsHeaderLabel = toolsHeader.createSpan({ text: "Tools" });
this.addTooltip(editToolsHeaderLabel, "CLI commands, API endpoints, and tool definitions available to agents using this skill");
const toolsTextarea = toolsSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "## Commands\n\n### list\n...", rows: "8" },
});
toolsTextarea.value = skill.toolsBody;
toolsTextarea.addEventListener("input", () => { state.toolsBody = toolsTextarea.value; });
// ─── References Section ───
const refsSection = form.createDiv({ cls: "af-create-section" });
const refsHeader = refsSection.createDiv({ cls: "af-create-section-header" });
const refsIcon = refsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(refsIcon, "book-open");
const refsHeaderLabel = refsHeader.createSpan({ text: "References" });
this.addTooltip(refsHeaderLabel, "Background docs, conventions, cheat sheets");
const refsTextarea = refsSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "API docs, filter syntax, conventions...", rows: "6" },
});
refsTextarea.value = skill.referencesBody;
refsTextarea.addEventListener("input", () => { state.referencesBody = refsTextarea.value; });
// ─── Examples Section ───
const examplesSection = form.createDiv({ cls: "af-create-section" });
const examplesHeader = examplesSection.createDiv({ cls: "af-create-section-header" });
const examplesIcon = examplesHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(examplesIcon, "message-circle");
const editExamplesHeaderLabel = examplesHeader.createSpan({ text: "Examples" });
this.addTooltip(editExamplesHeaderLabel, "Example prompts and ideal outputs showing how to use this skill");
const examplesTextarea = examplesSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "## Example: List all tasks\n...", rows: "6" },
});
examplesTextarea.value = skill.examplesBody;
examplesTextarea.addEventListener("input", () => { state.examplesBody = examplesTextarea.value; });
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" });
createIcon(deleteBtn, "trash-2", "af-btn-icon");
deleteBtn.appendText(" Delete");
deleteBtn.onclick = async () => {
await this.plugin.repository.deleteSkill(skill.name);
new Notice(`Skill "${skill.name}" deleted.`);
// Small delay for Obsidian vault cache to process the trash
await new Promise((r) => window.setTimeout(r, 200));
await this.plugin.refreshFromVault();
this.navigate("skills");
};
footer.createDiv({ cls: "af-toolbar-spacer" });
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => this.navigate("skills");
const saveBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(saveBtn, "check", "af-btn-icon");
saveBtn.appendText(" Save Changes");
saveBtn.onclick = async () => {
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
try {
await this.plugin.repository.updateSkill(skill.name, {
description: state.description.trim(),
tags: parseTags(state.tags),
body: state.body.trim(),
toolsBody: state.toolsBody.trim(),
referencesBody: state.referencesBody.trim(),
examplesBody: state.examplesBody.trim(),
});
new Notice(`Skill "${skill.name}" updated.`);
await this.plugin.refreshFromVault();
this.navigate("skills");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to update skill: ${msg}`);
}
};
}
// ═══════════════════════════════════════════════════════
// MCP Servers Page
// ═══════════════════════════════════════════════════════
/** Render the per-agent MCP grant picker from the fleet registry. Shared by
* the create- and edit-agent forms. `selected` is the agent's
* `mcpServers` set, mutated in place as checkboxes toggle. */
private renderAgentMcpPicker(section: HTMLElement, selected: Set<string>): void {
section.createDiv({
cls: "af-form-hint",
text:
"Servers from the MCP Servers tab. Checked servers are available to this " +
"agent on any adapter (Claude or Codex). Leave all unchecked to grant every enabled server.",
});
const servers = this.plugin.repository.getMcpServers();
if (servers.length === 0) {
const hint = section.createDiv({ cls: "af-form-hint" });
hint.appendText("No MCP servers registered yet. ");
const link = hint.createEl("a", { cls: "af-link", text: "Add one in the MCP Servers tab." });
link.onclick = (e) => { e.preventDefault(); this.navigate("mcp"); };
return;
}
const grid = section.createDiv({ cls: "af-create-skills-grid" });
for (const server of servers) {
const item = grid.createDiv({ cls: "af-mcp-agent-item" });
const cb = item.createEl("input", { cls: "af-form-toggle", attr: { type: "checkbox" } });
cb.checked = selected.has(server.name);
cb.addEventListener("change", () => {
if (cb.checked) selected.add(server.name);
else selected.delete(server.name);
});
const lbl = item.createDiv({ cls: "af-mcp-agent-label" });
const nameRow = lbl.createDiv({ cls: "af-mcp-agent-name-row" });
const dot = nameRow.createSpan({ cls: `af-mcp-status-dot ${server.enabled ? "idle" : "disabled"}` });
dot.title = server.enabled ? "enabled" : "disabled";
nameRow.createSpan({ cls: "af-create-skill-name", text: server.name });
nameRow.createSpan({ cls: "af-mcp-agent-tool-count", text: server.type });
if (!server.enabled) {
nameRow.createSpan({ cls: "af-mcp-agent-tool-count af-muted", text: "disabled" });
}
}
}
private renderMcpPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-agents-page" });
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "MCP Servers" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
const addBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" });
createIcon(addBtn, "plus", "af-btn-icon");
addBtn.appendText(" Add Server");
addBtn.onclick = () => this.navigate("add-mcp-server");
const servers = this.plugin.repository.getMcpServers();
if (servers.length === 0) {
this.renderEmptyState(page, "plug", "No MCP servers registered", "Click 'Add Server' above to register one.");
return;
}
const grid = page.createDiv({ cls: "af-agents-grid" });
for (const server of servers) {
this.renderMcpCard(grid, server);
}
}
/** Transient per-server tool probe results (name → tools), populated by the
* on-demand "Probe tools" action. Not persisted. */
private mcpProbeCache = new Map<string, McpTool[]>();
/** Whether the server has a stored auth token (OAuth/static bearer). */
private mcpHasToken(server: McpServer): boolean {
return this.plugin.mcpAuth.hasToken(server.name);
}
/** Whether an http/sse server still needs the user to authenticate (auth is
* oauth/bearer but no token is stored yet). */
private mcpNeedsAuth(server: McpServer): boolean {
return (
server.type !== "stdio" &&
(server.auth === "oauth" || server.auth === "bearer") &&
!this.mcpHasToken(server)
);
}
private renderMcpCard(container: HTMLElement, server: McpServer): void {
const card = container.createDiv({ cls: `af-mcp-card${server.enabled ? "" : " af-mcp-card-disabled"}` });
const needsAuth = this.mcpNeedsAuth(server);
const header = card.createDiv({ cls: "af-agent-card-header" });
const statusClass = !server.enabled ? "disabled" : needsAuth ? "pending" : "idle";
const avatarEl = header.createDiv({ cls: `af-agent-card-avatar ${statusClass}` });
setIcon(avatarEl, "plug");
const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" });
titleBlock.createDiv({ cls: "af-agent-card-name", text: server.name });
const metaRow = titleBlock.createDiv({ cls: "af-agent-card-desc af-mcp-meta" });
metaRow.createSpan({ cls: "af-mcp-type-badge", text: server.type });
if (server.source === "imported") {
metaRow.createSpan({ cls: "af-badge", text: "imported" });
}
// Enable/disable toggle — writes the registry file frontmatter.
const toggle = header.createDiv({ cls: `af-agent-card-toggle${server.enabled ? " on" : ""}` });
toggle.onclick = (e) => {
e.stopPropagation();
void this.plugin.repository.setMcpServerEnabled(server.name, !server.enabled).then(async () => {
await this.plugin.refreshFromVault();
void this.render();
});
};
// Status badge
const statusBadge = card.createDiv({ cls: `af-mcp-status-badge ${!server.enabled ? "disabled" : needsAuth ? "needs-auth" : "connected"}` });
const statusIcon = statusBadge.createSpan();
if (!server.enabled) {
setIcon(statusIcon, "pause");
statusBadge.createSpan({ text: " Disabled" });
} else if (needsAuth) {
setIcon(statusIcon, "alert-circle");
statusBadge.createSpan({ text: " Needs auth" });
} else {
setIcon(statusIcon, "check-circle");
statusBadge.createSpan({ text: server.type === "stdio" ? " Enabled" : " Authenticated" });
}
if (server.description) {
const desc = this.truncateDescription(server.description, 120);
card.createDiv({ cls: "af-mcp-description", text: desc });
}
const urlOrCmd = server.url ?? server.command ?? "";
if (urlOrCmd) {
card.createDiv({ cls: "af-mcp-command", text: truncate(urlOrCmd, 60) });
}
// Tool count from the on-demand probe cache (if probed this session).
const probed = this.mcpProbeCache.get(server.name);
if (probed && probed.length > 0) {
const toolFooter = card.createDiv({ cls: "af-mcp-tool-footer" });
const toolCount = toolFooter.createDiv({ cls: "af-mcp-tool-count" });
const toolIcon = toolCount.createSpan();
setIcon(toolIcon, "wrench");
toolCount.createSpan({ text: ` ${probed.length} tools` });
const chips = toolFooter.createDiv({ cls: "af-mcp-tool-chips" });
for (const t of probed.slice(0, 4)) {
chips.createSpan({ cls: "af-mcp-tool-chip", text: t.name });
}
if (probed.length > 4) {
chips.createSpan({ cls: "af-mcp-tool-chip af-mcp-tool-chip-more", text: `+${probed.length - 4}` });
}
}
// Auth / authenticating indicator for http/sse servers.
if (this.authenticatingServers.has(server.name)) {
const authRow = card.createDiv({ cls: "af-mcp-auth-row" });
const authBtn = authRow.createEl("button", { cls: "af-btn-sm primary", attr: { disabled: "true" } });
const spinIcon = authBtn.createSpan({ cls: "af-spin" });
setIcon(spinIcon, "loader-2");
authBtn.appendText(" Authenticating…");
} else if (server.enabled && needsAuth && server.auth === "oauth") {
const authRow = card.createDiv({ cls: "af-mcp-auth-row" });
const authBtn = authRow.createEl("button", { cls: "af-btn-sm primary" });
const authIcon = authBtn.createSpan();
setIcon(authIcon, "key");
authBtn.appendText(" Authenticate");
authBtn.onclick = (e) => {
e.stopPropagation();
void this.authenticateMcpServer(server);
};
}
card.onclick = () => this.openMcpDetailSlideover(server);
}
private async authenticateMcpServer(server: McpServer): Promise<void> {
if (!server.url) {
new Notice("No URL found for this server — can't authenticate.");
return;
}
this.authenticatingServers.add(server.name);
void this.render();
new Notice(`Authenticating ${server.name}… Complete authorization in your browser.`, 10000);
try {
const transport = server.type === "sse" ? "sse" : "http";
await this.plugin.mcpManager.authenticateServer(server.name, server.url, transport);
new Notice(`${server.name} authenticated successfully!`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Authentication failed: ${msg}`, 8000);
} finally {
this.authenticatingServers.delete(server.name);
void this.render();
}
}
/** On-demand tool probe for the MCP detail view. Stores results in the
* transient cache and re-renders. */
private async probeMcpServer(server: McpServer): Promise<void> {
try {
const tools = await this.plugin.mcpManager.probeServer(server);
this.mcpProbeCache.set(server.name, tools);
if (tools.length === 0) new Notice(`No tools discovered for ${server.name}.`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Probe failed: ${msg}`);
}
}
private truncateDescription(text: string, maxLen: number): string {
// Take first sentence or first line, whichever is shorter
const firstLine = splitLines(text)[0] ?? text;
const firstSentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
const candidate = firstSentence.length < firstLine.length ? firstSentence : firstLine;
if (candidate.length <= maxLen) return candidate;
return candidate.slice(0, maxLen - 1) + "…";
}
private openMcpDetailSlideover(server: McpServer): void {
this.contentEl.querySelector(".af-slideover-overlay")?.remove();
const overlay = this.contentEl.createDiv({ cls: "af-slideover-overlay" });
const panel = overlay.createDiv({ cls: "af-slideover" });
const header = panel.createDiv({ cls: "af-slideover-header" });
header.createDiv({ cls: "af-slideover-title", text: server.name });
const closeBtn = header.createEl("button", { cls: "clickable-icon" });
setIcon(closeBtn, "cross");
closeBtn.onclick = () => overlay.remove();
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
const body = panel.createDiv({ cls: "af-slideover-body" });
// Description
if (server.description) {
const descSection = body.createDiv({ cls: "af-slideover-section" });
descSection.createDiv({ cls: "af-slideover-section-title", text: "DESCRIPTION" });
descSection.createDiv({ cls: "af-mcp-detail-description", text: server.description });
}
// Server info
const infoSection = body.createDiv({ cls: "af-slideover-section" });
infoSection.createDiv({ cls: "af-slideover-section-title", text: "SERVER INFO" });
this.renderDetailRow(infoSection, "Name", server.name);
this.renderDetailRow(infoSection, "Transport", server.type);
this.renderDetailRow(infoSection, "Enabled", server.enabled ? "yes" : "no");
if (server.type !== "stdio") {
this.renderDetailRow(infoSection, "Auth", server.auth ?? "none");
this.renderDetailRow(infoSection, "Authenticated", this.mcpHasToken(server) ? "yes" : "no");
}
if (server.source) this.renderDetailRow(infoSection, "Source", server.source);
if (server.url) this.renderDetailRow(infoSection, "URL", server.url);
if (server.command) this.renderDetailRow(infoSection, "Command", server.command);
if (server.args && server.args.length > 0) this.renderDetailRow(infoSection, "Args", server.args.join(" "));
// Tools — populated by the on-demand probe (registry definitions carry no
// probed tools until the user clicks "Probe tools").
const probedTools = this.mcpProbeCache.get(server.name) ?? [];
const toolsSection = body.createDiv({ cls: "af-slideover-section" });
const toolsTitleRow = toolsSection.createDiv({ cls: "af-slideover-section-title" });
toolsTitleRow.setText(`TOOLS (${probedTools.length})`);
const probeBtn = toolsSection.createEl("button", { cls: "af-btn-sm" });
const probeIcon = probeBtn.createSpan();
setIcon(probeIcon, "wrench");
probeBtn.appendText(" Probe tools");
probeBtn.onclick = async () => {
probeBtn.disabled = true;
probeBtn.setText(" Probing…");
await this.probeMcpServer(server);
overlay.remove();
this.openMcpDetailSlideover(server);
};
if (probedTools.length > 0) {
for (const tool of probedTools) {
const toolItem = toolsSection.createDiv({ cls: "af-mcp-tool-detail" });
const toolHeader = toolItem.createDiv({ cls: "af-mcp-tool-detail-header" });
const toolNameEl = toolHeader.createSpan({ cls: "af-mcp-tool-detail-name" });
const toolNameIcon = toolNameEl.createSpan();
setIcon(toolNameIcon, "wrench");
toolNameEl.createSpan({ text: ` ${tool.name}` });
if (tool.inputSchema) {
const params = (tool.inputSchema as { required?: string[] }).required ?? [];
if (params.length > 0) {
toolHeader.createSpan({
cls: "af-mcp-tool-param-count",
text: `${params.length} param${params.length !== 1 ? "s" : ""}`,
});
}
}
if (tool.description) {
// Show first 2 lines of description, rest in collapsible
const descLines = splitLines(tool.description).filter((l) => l.trim());
const shortDesc = descLines.slice(0, 2).join(" ").trim();
const hasMore = descLines.length > 2;
if (hasMore) {
const details = toolItem.createEl("details", { cls: "af-mcp-tool-detail-desc" });
details.createEl("summary", { text: this.truncateDescription(shortDesc, 200) });
details.createDiv({ cls: "af-mcp-tool-detail-full", text: tool.description });
} else {
toolItem.createDiv({ cls: "af-mcp-tool-detail-desc", text: shortDesc });
}
}
// Input schema params
if (tool.inputSchema) {
const props = (tool.inputSchema as { properties?: Record<string, { type?: string; description?: string }> }).properties;
const required = new Set((tool.inputSchema as { required?: string[] }).required ?? []);
if (props && Object.keys(props).length > 0) {
const paramsEl = toolItem.createDiv({ cls: "af-mcp-tool-params" });
for (const [paramName, paramDef] of Object.entries(props)) {
const paramEl = paramsEl.createDiv({ cls: "af-mcp-tool-param" });
paramEl.createSpan({ cls: "af-mcp-tool-param-name", text: paramName });
if (paramDef.type) {
paramEl.createSpan({ cls: "af-mcp-tool-param-type", text: paramDef.type });
}
if (required.has(paramName)) {
paramEl.createSpan({ cls: "af-mcp-tool-param-required", text: "required" });
}
if (paramDef.description) {
paramEl.createSpan({
cls: "af-mcp-tool-param-desc",
text: truncate(paramDef.description, 80),
});
}
}
}
}
}
} else {
toolsSection.createDiv({
cls: "af-form-hint",
text: "Click \"Probe tools\" to discover the tools this server exposes.",
});
}
// Actions section
const actionsSection = body.createDiv({ cls: "af-slideover-section" });
actionsSection.createDiv({ cls: "af-slideover-section-title", text: "ACTIONS" });
if (server.enabled && server.url && server.auth === "oauth" && !this.mcpHasToken(server)) {
const authBtn = actionsSection.createEl("button", { cls: "af-btn-sm primary" });
const authIcon = authBtn.createSpan();
setIcon(authIcon, "key");
authBtn.appendText(" Authenticate");
authBtn.onclick = () => {
overlay.remove();
void this.authenticateMcpServer(server);
};
}
const removeBtn = actionsSection.createEl("button", { cls: "af-btn-sm danger" });
const removeIcon = removeBtn.createSpan();
setIcon(removeIcon, "trash-2");
removeBtn.appendText(" Remove Server");
removeBtn.onclick = async () => {
try {
await this.plugin.repository.deleteMcpServer(server.name);
this.plugin.mcpAuth.removeToken(server.name);
this.mcpProbeCache.delete(server.name);
new Notice(`Server "${server.name}" removed.`);
overlay.remove();
await this.plugin.refreshFromVault();
void this.render();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to remove server: ${msg}`);
}
};
}
// ═══════════════════════════════════════════════════════
// Add MCP Server Page
// ═══════════════════════════════════════════════════════
private renderAddMcpServerPage(container: HTMLElement): void {
const page = container.createDiv({ cls: "af-create-agent-page" });
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(avatar, "plus");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: "Add MCP Server" });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Register a new MCP server for agents to use" });
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
// Form state
const state: {
name: string;
transport: "stdio" | "http" | "sse";
description: string;
command: string;
args: string;
envVars: string;
url: string;
headers: string;
auth: "none" | "bearer" | "oauth";
bearerToken: string;
} = {
name: "",
transport: "stdio",
description: "",
command: "",
args: "",
envVars: "",
url: "",
headers: "",
auth: "none",
bearerToken: "",
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Server Details ───
const detailsSection = form.createDiv({ cls: "af-create-section" });
const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" });
const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(detailsIcon, "plug");
detailsHeader.createSpan({ text: "Server Details" });
this.createFormField(detailsSection, "Name", "my-server", "Unique name for this MCP server", (v) => { state.name = v; });
// Transport dropdown
const transportRow = detailsSection.createDiv({ cls: "af-form-row" });
const transportLabel = transportRow.createDiv({ cls: "af-form-label" });
transportLabel.setText("Transport");
this.addTooltip(transportLabel, "stdio: local process, http/sse: remote server");
const transportSelect = transportRow.createEl("select", { cls: "af-form-select" });
transportSelect.createEl("option", { text: "stdio", attr: { value: "stdio" } });
transportSelect.createEl("option", { text: "http", attr: { value: "http" } });
transportSelect.createEl("option", { text: "sse", attr: { value: "sse" } });
this.createFormField(detailsSection, "Description", "What this server does (optional)", "Shown on the server card", (v) => { state.description = v; });
// ─── stdio fields ───
const stdioSection = form.createDiv({ cls: "af-create-section" });
const stdioHeader = stdioSection.createDiv({ cls: "af-create-section-header" });
const stdioIcon = stdioHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(stdioIcon, "terminal");
stdioHeader.createSpan({ text: "Process Configuration" });
this.createFormField(stdioSection, "Command", "npx @anthropic-ai/mcp-server-memory", "The command to run", (v) => { state.command = v; });
this.createFormField(stdioSection, "Arguments", "--port 3000", "Space-separated arguments (optional)", (v) => { state.args = v; });
const envLabel = stdioSection.createDiv({ cls: "af-form-label" });
envLabel.setText("Environment variables");
this.addTooltip(envLabel, "One KEY=VALUE per line");
const envTextarea = stdioSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "API_KEY=sk-...\nDEBUG=true", rows: "3" },
});
envTextarea.addEventListener("input", () => { state.envVars = envTextarea.value; });
// ─── http/sse fields ───
const httpSection = form.createDiv({ cls: "af-create-section" });
const httpHeader = httpSection.createDiv({ cls: "af-create-section-header" });
const httpIcon = httpHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(httpIcon, "globe");
httpHeader.createSpan({ text: "Remote Server Configuration" });
this.createFormField(httpSection, "URL", "https://mcp.example.com/sse", "Server endpoint URL", (v) => { state.url = v; });
const headersLabel = httpSection.createDiv({ cls: "af-form-label" });
headersLabel.setText("Custom headers");
this.addTooltip(headersLabel, "One Header: Value per line (optional, non-secret)");
const headersTextarea = httpSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "X-Custom-Header: value", rows: "2" },
});
headersTextarea.addEventListener("input", () => { state.headers = headersTextarea.value; });
// Auth dropdown
const authRow = httpSection.createDiv({ cls: "af-form-row" });
const authLabel = authRow.createDiv({ cls: "af-form-label" });
authLabel.setText("Authentication");
this.addTooltip(authLabel, "none, a static bearer token, or OAuth (authenticate after saving)");
const authSelect = authRow.createEl("select", { cls: "af-form-select" });
authSelect.createEl("option", { text: "None", attr: { value: "none" } });
authSelect.createEl("option", { text: "Bearer token", attr: { value: "bearer" } });
authSelect.createEl("option", { text: "OAuth", attr: { value: "oauth" } });
// Bearer token field (shown only for auth=bearer). Stored in the keychain,
// never in the vault.
const bearerRow = httpSection.createDiv({ cls: "af-form-row" });
const bearerLabel = bearerRow.createDiv({ cls: "af-form-label" });
bearerLabel.setText("Bearer token");
this.addTooltip(bearerLabel, "Stored securely in the OS keychain, never written to the vault");
const bearerInput = bearerRow.createEl("input", { cls: "af-form-input", attr: { type: "password", placeholder: "sk-…" } });
bearerInput.addEventListener("input", () => { state.bearerToken = bearerInput.value; });
const updateAuthVisibility = () => {
bearerRow.setCssStyles({ display: state.auth === "bearer" ? "" : "none" });
};
authSelect.addEventListener("change", () => {
state.auth = authSelect.value as "none" | "bearer" | "oauth";
updateAuthVisibility();
});
updateAuthVisibility();
// Toggle section visibility based on transport
const updateTransportVisibility = () => {
stdioSection.setCssStyles({ display: state.transport === "stdio" ? "" : "none" });
httpSection.setCssStyles({ display: state.transport !== "stdio" ? "" : "none" });
};
transportSelect.addEventListener("change", () => {
state.transport = transportSelect.value as "stdio" | "http" | "sse";
updateTransportVisibility();
});
updateTransportVisibility();
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => this.navigate("mcp");
const submitBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(submitBtn, "plus", "af-btn-icon");
submitBtn.appendText(" Add Server");
submitBtn.onclick = async () => {
const name = state.name.trim();
if (!name) { new Notice("Server name is required."); return; }
if (state.transport === "stdio") {
if (!state.command.trim()) { new Notice("Command is required for stdio servers."); return; }
} else {
if (!state.url.trim()) { new Notice("URL is required for HTTP/SSE servers."); return; }
}
// Parse env vars
const envVars: Record<string, string> = {};
if (state.envVars.trim()) {
for (const line of splitLines(state.envVars)) {
const trimmed = line.trim();
if (!trimmed) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx <= 0) { new Notice(`Invalid env var: ${trimmed}`); return; }
envVars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
}
}
// Parse headers
const headers: Record<string, string> = {};
if (state.headers.trim()) {
for (const line of splitLines(state.headers)) {
const trimmed = line.trim();
if (!trimmed) continue;
const colonIdx = trimmed.indexOf(":");
if (colonIdx <= 0) { new Notice(`Invalid header: ${trimmed}`); return; }
headers[trimmed.slice(0, colonIdx).trim()] = trimmed.slice(colonIdx + 1).trim();
}
}
// Parse args
const args = state.args.trim() ? state.args.trim().split(/\s+/) : undefined;
if (this.plugin.repository.getMcpServerByName(name)) {
new Notice(`An MCP server named "${name}" already exists.`);
return;
}
if (state.transport !== "stdio" && state.auth === "bearer" && !state.bearerToken.trim()) {
new Notice("Enter a bearer token, or choose a different auth method.");
return;
}
submitBtn.disabled = true;
submitBtn.setText("Adding...");
// Build the registry definition (non-secret fields only). The bearer
// token, if any, goes to the keychain — never into the vault file.
const server: McpServer = {
name,
type: state.transport,
enabled: true,
source: "manual",
status: "disconnected",
scope: "user",
tools: [],
toolDetails: [],
};
if (state.transport === "stdio") {
server.command = state.command.trim();
if (args) server.args = args;
if (Object.keys(envVars).length > 0) server.env = envVars;
} else {
server.url = state.url.trim();
if (Object.keys(headers).length > 0) server.headers = headers;
server.auth = state.auth;
}
try {
await this.plugin.repository.saveMcpServer(server, state.description.trim());
if (state.transport !== "stdio" && state.auth === "bearer" && state.bearerToken.trim()) {
this.plugin.mcpAuth.storeStaticToken(name, state.bearerToken.trim());
}
new Notice(`Server "${name}" added.`);
await this.plugin.refreshFromVault();
this.navigate("mcp");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to add server: ${msg}`);
submitBtn.disabled = false;
submitBtn.setText("");
createIcon(submitBtn, "plus", "af-btn-icon");
submitBtn.appendText(" Add Server");
}
};
}
private createFormField(container: HTMLElement, label: string, placeholder: string, desc: string, onChange: (v: string) => void, initialValue?: string): void {
const row = container.createDiv({ cls: "af-form-row" });
const labelEl = row.createDiv({ cls: "af-form-label" });
labelEl.setText(label);
if (desc) this.addTooltip(labelEl, desc);
const input = row.createEl("input", {
cls: "af-form-input",
attr: { type: "text", placeholder },
});
if (initialValue !== undefined) {
input.value = initialValue;
}
input.addEventListener("input", () => onChange(input.value));
}
/** Append a small info icon with a hover tooltip to a label element. */
private addTooltip(labelEl: HTMLElement, text: string): void {
const tip = labelEl.createSpan({ cls: "af-form-tooltip" });
setIcon(tip, "info");
tip.createSpan({ cls: "af-tooltip-text", text });
}
}
/**
* Map a channel status to the existing `af-agent-card-avatar` color class so that
* channel cards borrow the same visual language as agent cards (green=healthy,
* blue=transitioning, red=broken, gray=off).
*/
function channelStatusToAvatarClass(status: string): string {
switch (status) {
case "connected":
return "idle"; // green
case "connecting":
case "reconnecting":
return "pending"; // blue
case "needs-auth":
case "error":
return "error"; // red
case "stopped":
case "disabled":
default:
return "disabled"; // gray
}
}
/**
* Format a token count for compact display:
* 0 → "0"
* 999 → "999"
* 1,500 → "1.5K"
* 45,000 → "45K"
* 1,200,000 → "1.2M"
*/
function formatTokenCount(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 10_000) return `${Math.round(tokens / 1000)}K`;
if (tokens >= 1_000) return `${(tokens / 1000).toFixed(1)}K`;
return String(tokens);
}
/** Convert a cron expression to a human-readable schedule description. */
function cronToHuman(cron: string): string {
if (!cron?.trim()) return "not set";
const cronMap: Record<string, string> = {
"*/5 * * * *": "Every 5 minutes",
"*/10 * * * *": "Every 10 minutes",
"*/15 * * * *": "Every 15 minutes",
"*/30 * * * *": "Every 30 minutes",
"0 * * * *": "Every hour",
"0 */2 * * *": "Every 2 hours",
"0 */4 * * *": "Every 4 hours",
"0 */6 * * *": "Every 6 hours",
"0 */12 * * *": "Every 12 hours",
};
if (cronMap[cron]) return cronMap[cron]!;
// Parse daily/weekly patterns: "M H * * *" or "M H * * 1-5"
const parts = cron.trim().split(/\s+/);
if (parts.length === 5) {
const [m, h, dom, , dow] = parts;
if (dom === "*" && h && m) {
const hour = Number(h);
const minute = Number(m);
if (!isNaN(hour) && !isNaN(minute)) {
const ampm = hour >= 12 ? "PM" : "AM";
const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
const timeStr = `${h12}:${String(minute).padStart(2, "0")} ${ampm}`;
if (dow === "*") return `Daily at ${timeStr}`;
if (dow === "1-5") return `Weekdays at ${timeStr}`;
return `${timeStr} on days ${dow}`;
}
}
}
return cron; // fallback to raw cron if no match
}
/** Map a channel status to an `af-pill` color variant for the header badge. */
function channelStatusPillColor(status: string): string {
switch (status) {
case "connected":
return "green";
case "connecting":
case "reconnecting":
return "blue";
case "needs-auth":
case "error":
return "red";
case "stopped":
case "disabled":
default:
return "";
}
}