gorazenxu_pimate/PiAgentView.ts
gorazen 5596294c44 优(model-sync): 以 Pi getState() 为权威数据源,思考档位弹窗按 thinkingLevelMap 动态渲染
- PiAgentClient 新增 PiModel/PiAgentState/AvailableModelsResult/SetModelResult 类型; sendCommand<T> 泛型化
- 新增 syncSeq 单调计数器废弃过时 getState() 响应;废弃旧 piModelMeta local 类型改用 Pi 返回的 PiModel
- updateActiveTabModel 改以 setModel RPC 响应中的 Pi 确认状态写入 tab/settings,避免 local 值与 Pi clamp 结果不一致
- 新增 syncTabStateFromPi() 统一同步入口,替代散落在各处的局部写入
- thinking_level_changed / model_changed 事件触发 syncTabStateFromPi() 权威同步
- 设置页 provider/model/effort 联动改走 updateActiveTabModel 统一入口,catch setModel 异常防崩溃
- 思考档位弹窗不再用静态硬编码列表,改为从 model.thinkingLevelMap keys 动态渲染;非推理模型显示 informational 行
- getStaticLevelDescription() 提供已知档位的中英文 label 映射

- 文档(CHANGELOG): archive working 条目到 v1.0.43
2026-07-11 02:38:04 +08:00

6904 lines
238 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
WorkspaceLeaf,
Notice,
MarkdownRenderer,
FileSystemAdapter,
Modal,
App,
SuggestModal,
TFile,
TFolder,
MarkdownView,
Menu,
setIcon,
} from "obsidian";
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
import { basename, dirname, join, relative } from "path";
import { homedir } from "os";
import type PiAgentPlugin from "./main";
import {
PiAgentClient,
type RpcEvent,
type AssistantMessageEvent,
type MessageContent,
type PiModel as PiModelFromClient,
} from "./PiAgentClient";
export const PI_AGENT_VIEW_TYPE = "pimate-chat-view";
// ─── Message Rendering Types ────────────────────────────────────────────
// Use Pi model metadata from the client (includes reasoning / thinkingLevelMap).
// Re-exported here to keep local usage ergonomic.
type PiModel = PiModelFromClient;
interface PiCommand {
name: string;
description?: string;
source?: string;
path?: string;
}
interface ForkMessage {
entryId: string;
text: string;
}
interface ResumeSessionItem {
path: string;
label: string;
mtime: number;
preview?: string;
}
interface ParsedSnippet {
title: string;
content: string;
group?: string;
}
interface ContextItem {
id: string;
type: "file" | "folder" | "selection" | "image";
label: string;
value: string;
mimeType?: string;
}
interface ChatTab {
id: string;
label: string;
client: PiAgentClient | null;
isStreaming: boolean;
queueCount?: number;
modelProvider?: string;
modelId?: string;
thinkingLevel?: string;
// Last known model metadata reported by Pi (only in memory; do not persist).
// Used to derive available thinking levels without re-querying every popup.
piModelMeta?: PiModel | null;
// Monotonic counter incremented before each Pi state sync; stale responses
// (older sequence) are discarded.
syncSeq?: number;
speedStartedAt?: number | null;
speedEstimatedTokens?: number;
speedHideAt?: number | null;
sessionFile?: string;
sessionId?: string;
restored?: boolean;
}
interface InlineEditReviewResult {
action: "apply" | "reject" | "regenerate";
replacement?: string;
}
interface RenderedMessage {
id: string;
role: string;
el: HTMLElement;
contentEl: HTMLElement;
// For streaming assistant messages
textBlock?: HTMLElement;
thinkingBlock?: HTMLElement;
thinkingContent?: HTMLElement;
toolBlocks?: Map<string, HTMLElement>;
}
interface MentionEntry {
kind: "file" | "folder";
file?: TFile;
folder?: TFolder;
score: number;
path: string;
name: string;
}
export class PiAgentView extends ItemView {
plugin: PiAgentPlugin;
client: PiAgentClient | null = null;
private chatContainer: HTMLElement | null = null;
private messageNavEl: HTMLElement | null = null;
private inputEl: HTMLTextAreaElement | null = null;
private streamingTextEl: HTMLElement | null = null;
private streamingCursorEl: HTMLElement | null = null;
private sessionTabsEl: HTMLElement | null = null;
private contextRowEl: HTMLElement | null = null;
private imagePreviewEl: HTMLElement | null = null;
private widgetEl: HTMLElement | null = null;
private abortBtn: HTMLButtonElement | null = null;
private statusBar: HTMLElement | null = null;
private speedEl: HTMLElement | null = null;
private speedStartedAt: number | null = null;
private speedEstimatedTokens = 0;
private speedTimer: number | null = null;
private speedHideTimer: number | null = null;
private footerModelLabel: HTMLElement | null = null;
private footerModelDropdown: HTMLElement | null = null;
private effortSelector: HTMLElement | null = null;
private effortGearsEl: HTMLElement | null = null;
private footerEffortCurrent: HTMLElement | null = null;
private footerEffortOptions: HTMLElement | null = null;
private compactedContextActive = false;
private footerContextEl: HTMLElement | null = null;
private footerContextFillEl: SVGCircleElement | null = null;
private footerContextPercentEl: HTMLElement | null = null;
private smartReviewToggleEl: HTMLElement | null = null;
private smartReviewContinues: number = 0;
private smartReviewOriginalGoal: string | null = null;
private renderedMessages: RenderedMessage[] = [];
private pendingUserImages: Array<{ data: string; mimeType: string }> = [];
// History paging (used for fast file-based load of large sessions).
private historyShownCount = 0; // currently displayed
private historyTotalCount = 0; // total messages in file
private historyBannerEl: HTMLElement | null = null;
private tabs: ChatTab[] = [];
private activeTabId: string | null = null;
private historyPanelEl: HTMLElement | null = null;
private modelPopupEl: HTMLElement | null = null;
private effortPopupEl: HTMLElement | null = null;
private isHistoryOpen = false;
private nextTabNumber = 1;
private contextItems: ContextItem[] = [];
private isStreaming = false;
private currentAssistantMsg: RenderedMessage | null = null;
private currentTextBlock: HTMLElement | null = null;
private currentThinkingBlock: HTMLElement | null = null;
private currentThinkingContent: HTMLElement | null = null;
private thinkingStartedAt: number | null = null;
private thinkingTimer: number | null = null;
private shouldAutoScroll = true;
private pendingUIRequests = new Map<string, (value: unknown) => void>();
// ─── Stream Render Helper States ────────────────────────────────────
private lastRenderTime = 0;
private renderTimeout: number | null = null;
private currentRawText = "";
private currentBlockRawText = "";
// ─── Autocomplete Mention Helper States ─────────────────────────────
private mentionDropdown: HTMLElement | null = null;
private filteredMentionFiles: MentionEntry[] = [];
private activeMentionIndex = 0;
private mentionQueryStart = -1;
// ─── Autocomplete Slash Command Helper States ───────────────────────
private commandDropdown: HTMLElement | null = null;
private filteredCommands: PiCommand[] = [];
private activeCommandIndex = 0;
private commandQueryStart = -1;
private availableCommands: PiCommand[] = [];
constructor(leaf: WorkspaceLeaf, plugin: PiAgentPlugin) {
super(leaf);
this.plugin = plugin;
}
setInputText(text: string): void {
if (!this.inputEl) return;
this.inputEl.value = text;
this.resizeInputEl();
this.inputEl.focus();
}
prependInputText(text: string): void {
if (!this.inputEl) return;
this.inputEl.value = text + this.inputEl.value;
this.resizeInputEl();
this.inputEl.focus();
}
appendInputText(text: string): void {
if (!this.inputEl) return;
const needsSpace = this.inputEl.value.length > 0 && !/\s$/.test(this.inputEl.value);
this.inputEl.value = `${this.inputEl.value}${needsSpace ? " " : ""}${text}`;
this.resizeInputEl();
this.inputEl.focus();
}
private resizeInputEl(): void {
if (!this.inputEl) return;
this.inputEl.setCssProps({ height: "auto" });
// 限制最大高度为 240px防止高度占满整个聊天视口
this.inputEl.setCssProps({ height: `${Math.min(this.inputEl.scrollHeight, 240)}px` });
}
focusComposer(): void {
this.inputEl?.focus();
}
private runAsync(task: () => Promise<void>): void {
void task().catch((err: unknown) => {
console.error("[pimate] async action failed", err);
new Notice(err instanceof Error ? err.message : String(err));
});
}
async newChatSession(): Promise<void> {
await this.newSession();
}
async closeActiveSessionTab(): Promise<void> {
if (this.activeTabId) await this.closeTab(this.activeTabId);
}
async resumePreviousSession(): Promise<void> {
await this.showResumeSelector();
}
async forkFromPreviousPrompt(): Promise<void> {
await this.showForkSelector();
}
async cloneCurrentSessionBranch(): Promise<void> {
await this.cloneCurrentBranch();
}
scrollToPreviousMessage(): void {
this.focusAdjacentMessage(-1);
}
scrollToNextMessage(): void {
this.focusAdjacentMessage(1);
}
toggleLastToolBlock(): void {
const outputs = Array.from(this.chatContainer?.querySelectorAll(".pi-agent-tool-output") || []) as HTMLElement[];
const output = outputs[outputs.length - 1];
if (output) output.toggleClass("is-visible", !output.hasClass("is-visible"));
}
scrollToLastDiff(): void {
const diff = Array.from(this.chatContainer?.querySelectorAll(".pi-agent-diff-pre") || []).pop() as HTMLElement | undefined;
diff?.scrollIntoView({ block: "center", behavior: "smooth" });
}
addActiveFileContext(): void {
this.addCurrentFileContext();
}
addExplorerSelectionContext(): void {
const isZh = this.plugin.settings.language === "zh";
const items = this.plugin.getExplorerSelectionForContext();
if (items.length === 0) {
new Notice(
isZh
? "Pimate没有检测到文件管理器选中项。请先在左侧文件管理器中多选文件/文件夹。"
: "Pimate: no file-explorer selection detected. Select files/folders in the file explorer first."
);
return;
}
let count = 0;
for (const item of items) {
if (item instanceof TFile) {
this.addFileContextItem(item);
count++;
} else if (item instanceof TFolder) {
this.addFolderContextItem(item, true);
count++;
}
}
new Notice(
isZh
? `Pimate已附加 ${count} 个选中项到上下文`
: `Pimate: attached ${count} selected item${count === 1 ? "" : "s"} to context`
);
this.inputEl?.focus();
}
openCommandsAndSkills(): void {
this.runAsync(() => this.showCommandSelector());
}
addSelectionContext(selection: string): void {
const trimmed = selection.trim();
if (!trimmed) return;
this.addContextItem({
id: `ctx-sel-${Date.now()}-${Math.random().toString(36).slice(2)}`,
type: "selection",
label: `${trimmed.slice(0, 28)}${trimmed.length > 28 ? "…" : ""}`,
value: trimmed,
});
this.inputEl?.focus();
}
getViewType(): string {
return PI_AGENT_VIEW_TYPE;
}
getDisplayText(): string {
return "Pimate";
}
getIcon(): string {
return "pimate-logo";
}
async onOpen(): Promise<void> {
const container = this.containerEl.children[1];
container.empty();
container.addClass("pi-agent-container");
const isZh = this.plugin.settings.language === "zh";
// ─── Build UI ──────────────────────────────────────────────────────
const header = container.createDiv("pi-agent-header");
const titleEl = header.createDiv("pi-agent-title");
titleEl.createSpan({ text: "π", cls: "pi-agent-logo" });
titleEl.createSpan({ text: "Pimate" });
this.speedEl = header.createDiv("pi-agent-speed-indicator pi-agent-hidden");
this.speedEl.setAttribute("title", isZh ? "实时输出速度(估算)" : "Realtime output speed (estimated)");
// 右上角设置按钮 (按用户要求保留,放右上角合适的位置)
const headerActions = header.createDiv("pi-agent-header-actions");
const moreBtn = headerActions.createDiv("pi-agent-mini-action");
setIcon(moreBtn, "more-horizontal");
moreBtn.setAttribute("title", isZh ? "更多操作" : "More actions");
moreBtn.onclick = (e) => this.showMoreMenu(e);
const settingsBtn = headerActions.createDiv("pi-agent-mini-action");
setIcon(settingsBtn, "settings");
settingsBtn.setAttribute("title", isZh ? "插件设置" : "Plugin settings");
settingsBtn.onclick = () => {
const setting = (this.app as any).setting;
if (setting) {
setting.open();
setting.openTabById(this.plugin.manifest.id);
}
};
this.chatContainer = container.createDiv("pi-agent-chat");
this.renderEmptyState();
this.historyPanelEl = container.createDiv("pi-agent-history-panel");
this.historyPanelEl.addClass("pi-agent-hidden");
this.widgetEl = container.createDiv("pi-agent-widget");
this.widgetEl.addClass("pi-agent-hidden");
// Toolbar above input wrapper
const composerTools = container.createDiv("pi-agent-composer-tools");
this.sessionTabsEl = composerTools.createDiv("pi-agent-session-tabs");
const composerActions = composerTools.createDiv("pi-agent-composer-actions");
const newTabBtn = composerActions.createDiv("pi-agent-mini-action");
setIcon(newTabBtn, "square-plus");
newTabBtn.setAttribute("title", isZh ? "新建会话卡" : "New tab");
newTabBtn.onclick = () => {
const maxTabs = this.plugin.settings.maxTabs || 3;
if (this.tabs.length < maxTabs) {
this.runAsync(() => this.createAndSwitchTab());
} else {
new Notice(isZh ? `已达到最大会话卡数量限制 (${maxTabs})` : `Maximum tab count reached (${maxTabs})`);
}
};
// 新建按钮右键:重置所有会话卡
newTabBtn.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
const menu = new Menu();
menu.addItem((item: any) => {
item.setTitle(isZh ? "重置所有会话卡 (1, 2, 3)" : "Reset all session tabs")
.setIcon("refresh-cw")
.onClick(() => this.runAsync(async () => {
for (const t of this.tabs) {
await t.client?.destroy();
t.client = null;
t.sessionFile = undefined;
t.sessionId = undefined;
t.restored = false;
t.label = t.id.split("-").pop() || "Tab";
}
if (this.chatContainer) this.chatContainer.empty();
this.renderedMessages = [];
this.renderEmptyState();
const active = this.activeTab;
if (active) {
await this.ensureTabClient(active);
this.client = active.client;
await this.refreshStateDisplay();
await this.loadAvailableCommands();
}
this.renderTabs();
this.updateButtons();
await this.persistSessionTabs();
new Notice(isZh ? "所有会话卡均已重置" : "All session tabs reset");
}));
});
menu.showAtMouseEvent(e);
});
const forkBtn = composerActions.createDiv("pi-agent-mini-action");
setIcon(forkBtn, "square-pen");
forkBtn.setAttribute("title", isZh ? "新建/重置当前会话" : "New conversation");
forkBtn.onclick = () => this.runAsync(() => this.newSession());
// 分支按钮右键:分支或克隆
forkBtn.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
const menu = new Menu();
menu.addItem((item: any) => {
item.setTitle(isZh ? "分支当前会话..." : "Fork current conversation...")
.setIcon("git-fork")
.onClick(() => this.runAsync(() => this.showForkSelector()));
});
menu.addItem((item: any) => {
item.setTitle(isZh ? "克隆当前会话分支" : "Clone current branch")
.setIcon("copy")
.onClick(() => this.runAsync(() => this.cloneCurrentBranch()));
});
menu.showAtMouseEvent(e);
});
const historyBtn = composerActions.createDiv("pi-agent-mini-action");
setIcon(historyBtn, "history");
historyBtn.setAttribute("title", isZh ? "恢复会话/历史" : "History sessions");
historyBtn.onclick = () => this.runAsync(() => this.toggleHistoryPanel());
// 历史按钮右键:在系统管理器中打开历史目录
historyBtn.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
const menu = new Menu();
menu.addItem((item: any) => {
item.setTitle(isZh ? "打开历史会话保存目录" : "Open history sessions directory")
.setIcon("folder")
.onClick(() => {
try {
const historyDir = join(homedir(), ".pi", "sessions");
const electron = (window as unknown as { require?: (moduleName: string) => { shell?: { openPath: (target: string) => Promise<string> } } }).require?.("electron");
void electron?.shell?.openPath(historyDir);
} catch (err) {
new Notice(isZh ? `无法打开目录: ${(err as Error).message}` : `Cannot open dir: ${(err as Error).message}`);
}
});
});
menu.showAtMouseEvent(e);
});
const inputArea = container.createDiv("pi-agent-input-area");
this.contextRowEl = inputArea.createDiv("pi-agent-context-row");
this.imagePreviewEl = inputArea.createDiv("pi-agent-image-preview");
// Wrap textarea + right-side controls in a flex row. The right column
// contains the 4 message-nav buttons stacked vertically.
const inputRow = inputArea.createDiv("pi-agent-input-row");
this.inputEl = inputRow.createEl("textarea", {
cls: "pi-agent-input",
attr: {
placeholder: "How can I help you today?",
rows: "4",
},
});
// Right-side controls: 4 message-nav buttons stacked.
const rightCol = inputRow.createDiv("pi-agent-input-right");
const navEl = rightCol.createDiv("pi-agent-message-nav");
const mkNavBtn = (cls: string, icon: string, title: string, handler: () => void) => {
const btn = navEl.createDiv(`pi-agent-message-nav-btn ${cls}`);
setIcon(btn, icon);
btn.setAttribute("title", title);
btn.onclick = handler;
return btn;
};
mkNavBtn("is-first", "chevrons-up", "Jump to first user message", () => this.focusEdgeMessage("first"));
mkNavBtn("is-prev", "chevron-up", "Previous user message (Alt+↑)", () => this.focusAdjacentMessage(-1));
mkNavBtn("is-next", "chevron-down", "Next user message (Alt+↓)", () => this.focusAdjacentMessage(1));
mkNavBtn("is-last", "chevrons-down", "Jump to last user message", () => this.focusEdgeMessage("last"));
this.messageNavEl = navEl;
this.inputEl.addEventListener("keydown", (e: KeyboardEvent) => {
// 1. Mention autocomplete key intercepts
if (this.mentionDropdown && this.filteredMentionFiles.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
this.activeMentionIndex = (this.activeMentionIndex + 1) % this.filteredMentionFiles.length;
this.renderMentionDropdownItems();
return;
} else if (e.key === "ArrowUp") {
e.preventDefault();
this.activeMentionIndex =
(this.activeMentionIndex - 1 + this.filteredMentionFiles.length) %
this.filteredMentionFiles.length;
this.renderMentionDropdownItems();
return;
} else if (e.key === "Enter") {
e.preventDefault();
this.insertMentionSelection();
return;
} else if (e.key === "Escape") {
e.preventDefault();
this.closeMentionDropdown();
return;
}
}
// 2. Command autocomplete key intercepts
if (this.commandDropdown && this.filteredCommands.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
this.activeCommandIndex = (this.activeCommandIndex + 1) % this.filteredCommands.length;
this.renderCommandDropdownItems();
return;
} else if (e.key === "ArrowUp") {
e.preventDefault();
this.activeCommandIndex =
(this.activeCommandIndex - 1 + this.filteredCommands.length) %
this.filteredCommands.length;
this.renderCommandDropdownItems();
return;
} else if (e.key === "Enter") {
e.preventDefault();
this.insertCommandSelection();
return;
} else if (e.key === "Escape") {
e.preventDefault();
this.closeCommandDropdown();
return;
}
}
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
this.runAsync(() => this.sendMessage());
} else if (e.key === "/" && this.inputEl?.selectionStart === 0 && !this.inputEl.value) {
window.setTimeout(() => this.runAsync(() => this.showCommandSelector()), 0);
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
this.runAsync(() => this.showCommandSelector());
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "n") {
e.preventDefault();
this.runAsync(() => this.newSession());
} else if (e.altKey && e.key === "ArrowUp") {
e.preventDefault();
this.scrollToPreviousMessage();
} else if (e.altKey && e.key === "ArrowDown") {
e.preventDefault();
this.scrollToNextMessage();
} else if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "e") {
e.preventDefault();
this.toggleLastToolBlock();
} else if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "d") {
e.preventDefault();
this.scrollToLastDiff();
} else if (e.key === "Escape" && this.isStreaming) {
e.preventDefault();
this.abortAgent();
}
});
this.inputEl.addEventListener("paste", (e: ClipboardEvent) => {
this.runAsync(() => this.handlePaste(e));
});
this.inputEl.addEventListener("dragover", (e: DragEvent) => {
e.preventDefault();
this.inputEl?.addClass("is-drag-over");
});
this.inputEl.addEventListener("dragleave", () => {
this.inputEl?.removeClass("is-drag-over");
});
this.inputEl.addEventListener("drop", (e: DragEvent) => {
this.runAsync(() => this.handleDrop(e));
});
this.inputEl.addEventListener("input", () => {
this.updateInputModeState();
this.handleMentionInput();
this.handleCommandInput();
this.resizeInputEl();
});
const footer = inputArea.createDiv("pi-agent-input-footer");
const footerLeft = footer.createDiv("pi-agent-input-footer-left");
// 1. Model Selector Container (Compat with Claudian)
const modelSelector = footerLeft.createDiv("pi-agent-model-selector");
const footerModelBtn = modelSelector.createDiv("pi-agent-model-btn");
this.footerModelLabel = footerModelBtn.createSpan("pi-agent-model-label");
this.footerModelLabel.setText(this.getModelShortName(this.plugin.settings.modelId || "Sonnet"));
this.footerModelLabel.setAttribute("title", `${this.plugin.settings.provider || ""}/${this.plugin.settings.modelId || ""}`);
// 点击模型按钮,在按钮正上方弹起局部的模型选择浮层
footerModelBtn.onclick = (e) => {
e.stopPropagation();
this.runAsync(() => this.toggleModelPopup(footerModelBtn));
};
// 2. Effort Selector Container (Compat with Claudian)
this.effortSelector = footerLeft.createDiv("pi-agent-thinking-effort");
const effortLabel = this.effortSelector.createSpan("pi-agent-thinking-label-text");
effortLabel.setText(isZh ? "Effort:" : "Effort:");
this.effortGearsEl = this.effortSelector.createDiv("pi-agent-thinking-gears");
this.footerEffortCurrent = this.effortGearsEl.createDiv("pi-agent-thinking-current");
this.footerEffortCurrent.setText(this.getThinkingLevelLabel(this.plugin.settings.thinkingLevel));
// 点击思考强度,在上方弹起局部的思考强度选择浮层
this.effortGearsEl.onclick = (e) => {
e.stopPropagation();
if (this.effortGearsEl) {
this.runAsync(() => this.toggleEffortPopup(this.effortGearsEl!));
}
};
// 3. Folder Context Button
const folderBtn = footerLeft.createSpan({
cls: "pi-agent-footer-folder-btn",
attr: { title: isZh ? "选择文件上下文" : "Select file context" },
});
setIcon(folderBtn, "folder");
folderBtn.onclick = () => this.runAsync(() => this.addFileContext());
this.statusBar = footerLeft.createSpan({
text: "Starting…",
cls: "pi-agent-status pi-agent-status-thinking",
});
this.footerContextEl = footerLeft.createSpan({
cls: "pi-agent-context-meter-inline",
attr: { title: "Context usage" },
});
const svg = this.footerContextEl.createSvg("svg", {
attr: { viewBox: "0 0 24 24", width: "18", height: "18" },
});
svg.createSvg("circle", {
cls: "pi-agent-context-meter-bg",
attr: { cx: "12", cy: "12", r: "8", fill: "none", "stroke-width": "2" },
});
this.footerContextFillEl = svg.createSvg("circle", {
cls: "pi-agent-context-meter-fill",
attr: {
cx: "12",
cy: "12",
r: "8",
fill: "none",
"stroke-width": "2",
"stroke-linecap": "round",
},
}) as SVGCircleElement;
this.footerContextPercentEl = this.footerContextEl.createSpan({ text: "", cls: "pi-agent-context-meter-percent" });
const footerRight = footer.createDiv("pi-agent-input-footer-right");
// Smart Review Toggle
this.smartReviewToggleEl = footerRight.createSpan({ cls: "pi-agent-smart-review-toggle" });
this.smartReviewToggleEl.onclick = () => {
this.runAsync(async () => {
this.plugin.settings.smartReviewEnabled = !this.plugin.settings.smartReviewEnabled;
await this.plugin.saveSettings();
this.updateSmartReviewToggleUI();
});
};
this.updateSmartReviewToggleUI();
this.abortBtn = footerRight.createEl("button", {
text: "×",
cls: "pi-agent-footer-btn pi-agent-abort-btn",
attr: { title: "Abort" },
});
this.abortBtn.addClass("pi-agent-hidden");
this.abortBtn.onclick = () => this.abortAgent();
// ─── Start real Pi session tabs ───────────────────────────────────
await this.restoreOrCreateInitialTab();
}
// ─── Client Management ────────────────────────────────────────────────
private get activeTab(): ChatTab | null {
return this.tabs.find((tab) => tab.id === this.activeTabId) ?? null;
}
private async restoreOrCreateInitialTab(): Promise<void> {
const maxTabs = this.plugin.settings.maxTabs || 3;
const persisted = this.plugin.settings.sessionTabs || [];
this.tabs = [];
// 有历史缓存时按历史缓存的卡片数还原(保持关闭某些卡片后的数量),初次无缓存时直接满额开满 maxTabs 个
const count = persisted.length > 0 ? Math.min(persisted.length, maxTabs) : maxTabs;
for (let i = 1; i <= count; i++) {
const pTab = persisted[i - 1];
this.tabs.push({
id: `tab-static-${i}`,
label: String(i),
client: null,
isStreaming: false,
modelProvider: pTab?.modelProvider,
modelId: pTab?.modelId,
thinkingLevel: pTab?.thinkingLevel,
sessionFile: pTab?.sessionFile,
sessionId: pTab?.sessionId,
restored: !!pTab?.sessionFile,
});
}
const active =
this.tabs.find((tab) => tab.sessionFile?.toLowerCase() === this.plugin.settings.activeSessionFile?.toLowerCase()) ||
this.tabs[0];
this.activeTabId = active?.id || null;
this.renderTabs();
if (active) await this.switchToTab(active.id);
}
private async createAndSwitchTab(): Promise<void> {
const tab: ChatTab = {
id: `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label: "",
client: null,
isStreaming: false,
// New tabs always start from the global default provider/model/thinking
// (the same values the user sees in Plugin settings), so the tab
// doesn't accidentally inherit a stale value from the previous active
// tab after a provider switch.
modelProvider: this.plugin.settings.provider,
modelId: this.plugin.settings.modelId,
thinkingLevel: this.plugin.settings.thinkingLevel,
};
this.tabs.push(tab);
this.activeTabId = tab.id;
this.renderTabs();
await this.ensureTabClient(tab);
await this.switchToTab(tab.id);
await this.persistSessionTabs();
}
private renderTabs(): void {
if (!this.sessionTabsEl) return;
this.sessionTabsEl.empty();
const isZh = this.plugin.settings.language === "zh";
// 1. 渲染固定选项卡 1, 2, 3
for (let index = 0; index < this.tabs.length; index++) {
const tab = this.tabs[index];
const tabEl = this.sessionTabsEl.createSpan({
cls: `pi-agent-session-tab ${tab.id === this.activeTabId ? "is-active" : ""}`,
attr: { title: isZh ? `会话卡 ${index + 1}` : `Session ${index + 1}` },
});
tabEl.createSpan({ text: String(index + 1), cls: "pi-agent-session-tab-label" });
tabEl.onclick = () => this.runAsync(() => this.switchToTab(tab.id));
// 选项卡右键功能:直接关闭,无需二级菜单
tabEl.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
this.runAsync(() => this.closeTab(tab.id));
});
}
}
private async resetTabSession(tab: ChatTab): Promise<void> {
const isZh = this.plugin.settings.language === "zh";
if (tab.isStreaming) {
new Notice(isZh ? "该会话正在流式传输,请先停止" : "Streaming active, please stop first");
return;
}
await tab.client?.destroy();
tab.client = null;
tab.sessionFile = undefined;
tab.sessionId = undefined;
tab.restored = false;
tab.label = tab.id.split("-").pop() || "Tab";
if (tab.id === this.activeTabId) {
if (this.chatContainer) this.chatContainer.empty();
this.renderedMessages = [];
this.renderEmptyState();
await this.ensureTabClient(tab);
this.client = tab.client;
await this.refreshStateDisplay();
await this.loadAvailableCommands();
}
this.renderTabs();
this.updateButtons();
await this.persistSessionTabs();
new Notice(isZh ? `会话卡 ${tab.label} 已重置` : `Session tab ${tab.label} reset`);
}
private async switchToTab(tabId: string): Promise<void> {
const tab = this.tabs.find((item) => item.id === tabId);
if (!tab) return;
this.activeTabId = tab.id;
this.client = tab.client;
this.isStreaming = tab.isStreaming;
this.renderTabs();
this.resetActiveRenderState();
if (this.chatContainer) this.chatContainer.empty();
this.renderedMessages = [];
this.historyShownCount = 0;
this.historyTotalCount = 0;
this.historyBannerEl = null;
this.renderEmptyState();
this.updateWidget("tasks", undefined);
await this.ensureTabClient(tab);
this.client = tab.client;
this.renderActiveTabRuntimeStatus();
this.renderActiveTabSpeed();
// Parallelize non-blocking post-start calls so the UI feels snappy.
// Each call sets a different part of the UI and they don't depend on each other.
void this.refreshStateDisplay();
void this.loadAvailableCommands();
await this.loadMessages();
this.renderActiveTabRuntimeStatus();
this.renderActiveTabSpeed();
this.renderActiveTabModelAndEffort();
this.updateButtons();
void this.persistSessionTabs();
}
private async closeTab(tabId: string): Promise<void> {
const index = this.tabs.findIndex((tab) => tab.id === tabId);
if (index === -1) return;
const [tab] = this.tabs.splice(index, 1);
await tab.client?.destroy();
tab.client = null;
if (this.tabs.length === 0) {
this.activeTabId = null;
this.client = null;
await this.createAndSwitchTab();
return;
}
if (this.activeTabId === tabId) {
const next = this.tabs[Math.max(0, index - 1)] || this.tabs[0];
await this.switchToTab(next.id);
} else {
this.renderTabs();
}
await this.persistSessionTabs();
}
private isSessionFileInCurrentWorkspace(sessionFile: string): boolean {
if (!sessionFile) return false;
const vaultPath = (this.app.vault.adapter as any).getBasePath?.() || "";
if (!vaultPath) return true;
const encodedDirName = this.encodeWorkspacePath(vaultPath).toLowerCase();
const pathLower = sessionFile.toLowerCase().replace(/\\/g, "/");
return pathLower.includes(`/sessions/${encodedDirName}/`);
}
private async ensureTabClient(tab: ChatTab): Promise<void> {
if (tab.sessionFile && !this.isSessionFileInCurrentWorkspace(tab.sessionFile)) {
console.log(`[pi-agent] SessionFile ${tab.sessionFile} belongs to another workspace, unbinding to start fresh.`);
tab.sessionFile = undefined;
tab.sessionId = undefined;
tab.restored = false;
}
if (tab.client?.isRunning()) {
await this.applyTabRuntimePreferences(tab);
return;
}
const client = this.createClient(tab);
tab.client = client;
client.on("event", (event: RpcEvent) => {
this.recordTabRuntimeState(tab, event);
if (this.activeTabId !== tab.id) return;
this.handleEvent(event);
});
client.on("error", (err: Error) => {
if (this.activeTabId === tab.id) this.setStatus(`❌ Error: ${err.message}`, "error");
});
client.on("close", () => {
tab.isStreaming = false;
if (this.activeTabId === tab.id) {
this.setStatus("⚠️ Pi process disconnected", "warning");
this.isStreaming = false;
this.updateButtons();
}
});
try {
await client.start();
if (tab.sessionFile) {
const result = await client.switchSession(tab.sessionFile);
if (!result.success || (result.data as any)?.cancelled) {
new Notice(`Failed to restore session: ${tab.label}`);
}
}
await this.applyTabRuntimePreferences(tab);
if (this.activeTabId === tab.id) {
this.setStatus("Ready", "ok");
void this.loadAvailableCommands();
}
} catch (err) {
if (this.activeTabId === tab.id) {
this.setStatus(
`❌ Failed to start pi: ${(err as Error).message}`,
"error"
);
}
}
}
private createClient(tab?: ChatTab): PiAgentClient {
const settings = this.plugin.settings;
const adapter = this.app.vault.adapter;
const vaultBasePath =
adapter instanceof FileSystemAdapter ? adapter.getBasePath() : undefined;
const provider = tab?.modelProvider || settings.provider;
const modelId = tab?.modelId || settings.modelId;
const thinkingLevel = tab?.thinkingLevel || settings.thinkingLevel;
return new PiAgentClient({
piPath: settings.piPath,
provider,
modelId,
thinkingLevel,
// 优先用当前 provider 在 auth.json 里的 key面板"凭证配置区"填的),
// 否则回退到全局 settings.apiKey。PiAgentClient 会按 provider 把它
// 注入对应环境变量(如 ZHIPU_API_KEY让 pi 后端 models.json 的
// "$XXX_API_KEY" 能解析成功。
apiKey: this.readProviderApiKey(provider) || settings.apiKey,
cwd: vaultBasePath,
noSession: false,
});
}
// 按当前 provider 从 ~/.pi/agent/auth.json 读取 API Key。
// 与 PiAgentSettings.readApiKey 同源(面板"凭证配置区"写入的就是这里)。
// OAuth 类型(如 openai-codex不返回 key —— pi 后端自行用 auth.json 的 OAuth token。
private readProviderApiKey(provider: string): string {
try {
const filePath = join(homedir(), ".pi", "agent", "auth.json");
if (!existsSync(filePath)) return "";
const data = JSON.parse(readFileSync(filePath, "utf-8"));
const item = data?.[provider];
if (item && item.type === "api_key" && typeof item.key === "string") {
return item.key.trim();
}
return "";
} catch (e) {
console.warn("[pimate] readProviderApiKey failed:", e);
return "";
}
}
private async applyTabRuntimePreferences(tab: ChatTab): Promise<void> {
if (!tab.client) return;
try {
if (tab.modelProvider && tab.modelId) {
await tab.client.setModel(tab.modelProvider, tab.modelId);
}
if (typeof tab.thinkingLevel === "string") {
await tab.client.setThinkingLevel(tab.thinkingLevel);
}
} catch (err) {
console.warn("[pimate] failed to apply tab runtime preferences", err);
} finally {
// Pull authoritative state so any clamp by Pi overrides the
// persisted preference value (e.g. `xhigh` was saved but the
// resumed model only supports `high`).
await this.syncTabStateFromPi(tab);
}
}
private async updateActiveTabModel(provider: string, modelId: string): Promise<void> {
const tab = this.activeTab;
if (!tab?.client) {
// No active Pi client to talk to; fall back to plain settings update.
if (tab) {
tab.modelProvider = provider;
tab.modelId = modelId;
}
this.plugin.settings.provider = provider;
this.plugin.settings.modelId = modelId;
await this.plugin.saveSettings();
await this.persistSessionTabs();
this.updateModelDisplay(provider, modelId);
return;
}
let response;
try {
response = await tab.client.setModel(provider, modelId);
} catch (err) {
console.error("[pimate] setModel failed", err);
throw err;
}
if (!response?.success) {
throw new Error(response?.error || "setModel failed");
}
// Stash metadata returned by Pi so the effort popup can render correctly
// before the explicit getState() round-trip lands. Pi versions have
// returned both `data = model` and `data = { model }`; accept either.
tab.piModelMeta = this.extractPiModelFromRpcData(response.data);
// Now pull authoritative state from Pi — it may have clamped the
// current thinking level to a value the new model supports.
await this.syncTabStateFromPi(tab);
// The tab fields / global settings / footer / persistence are updated
// inside applyAuthoritativePiState when syncTabStateFromPi returns.
// If an effort popup is open, the displayed model just changed; close it
// so the user reopens with the new model's options.
if (this.effortPopupEl) this.closeEffortPopup();
}
private async updateActiveTabThinkingLevel(level: string): Promise<void> {
const tab = this.activeTab;
if (!tab?.client) {
if (tab) tab.thinkingLevel = level;
await this.persistSessionTabs();
if (this.footerEffortCurrent) {
this.footerEffortCurrent.setText(this.getThinkingLevelLabel(level));
}
return;
}
let response;
try {
response = await tab.client.setThinkingLevel(level);
} catch (err) {
console.error("[pimate] setThinkingLevel failed", err);
throw err;
}
if (!response?.success) {
throw new Error(response?.error || "setThinkingLevel failed");
}
// Pi may clamp; let getState() be the authoritative source.
await this.syncTabStateFromPi(tab);
}
// ─── Pi authoritative state sync ───────────────────────────────────────────
//
// Pulls full state from Pi and reconciles it into the in-memory tab,
// global settings, footer display, and persisted session. A monotonic
// sequence plus client reference check guards against late responses that
// arrive after a tab has switched clients or moved on to another sync.
private extractPiModelFromRpcData(data: unknown): PiModel | null {
if (!data || typeof data !== "object") return null;
const maybeWrapped = data as { model?: unknown };
if (maybeWrapped.model && typeof maybeWrapped.model === "object") {
const model = maybeWrapped.model as Partial<PiModel>;
if (typeof model.id === "string" && typeof model.provider === "string") {
return model as PiModel;
}
}
const maybeModel = data as Partial<PiModel>;
if (typeof maybeModel.id === "string" && typeof maybeModel.provider === "string") {
return maybeModel as PiModel;
}
return null;
}
private async syncTabStateFromPi(tab: ChatTab): Promise<void> {
if (!tab.client) return;
const client = tab.client;
const seq = (tab.syncSeq ?? 0) + 1;
tab.syncSeq = seq;
let response;
try {
response = await client.getState();
} catch (err) {
console.warn("[pimate] getState failed", err);
return;
}
// Drop stale responses: tab moved on, or its client was swapped out.
if (tab.syncSeq !== seq) return;
if (tab.client !== client) return;
if (!response?.success || !response.data) return;
this.applyAuthoritativePiState(tab, response.data);
}
private applyAuthoritativePiState(
tab: ChatTab,
state: import("./PiAgentClient").PiAgentState
): void {
const previousProvider = tab.modelProvider;
const previousModelId = tab.modelId;
const previousLevel = tab.thinkingLevel;
if (state.model) {
tab.piModelMeta = state.model;
tab.modelProvider = state.model.provider;
tab.modelId = state.model.id;
}
if (typeof state.thinkingLevel === "string") {
tab.thinkingLevel = state.thinkingLevel;
} else if (state.thinkingLevel == null && tab.piModelMeta?.reasoning === false) {
// Reasoning-off models may report no level at all; clear stale values.
tab.thinkingLevel = "";
}
const modelChanged =
previousProvider !== tab.modelProvider ||
previousModelId !== tab.modelId;
const levelChanged = previousLevel !== tab.thinkingLevel;
let settingsChanged = false;
// Reflect into global settings so newly created/restarted tabs inherit
// the Pi-confirmed pair (e.g. prevent openai-codex + MiniMax-M3 combos).
if (
modelChanged &&
tab.modelProvider &&
tab.modelId &&
tab === this.activeTab
) {
this.plugin.settings.provider = tab.modelProvider;
this.plugin.settings.modelId = tab.modelId;
settingsChanged = true;
}
if (
levelChanged &&
tab.thinkingLevel !== undefined &&
tab === this.activeTab
) {
this.plugin.settings.thinkingLevel = tab.thinkingLevel;
settingsChanged = true;
}
// Persist + notify footer only when something actually changed.
if (modelChanged || levelChanged) {
void this.persistSessionTabs();
if (settingsChanged) void this.plugin.saveSettings();
if (
modelChanged &&
tab.modelProvider &&
tab.modelId &&
tab === this.activeTab
) {
this.updateModelDisplay(tab.modelProvider, tab.modelId);
}
if (levelChanged && tab === this.activeTab && this.footerEffortCurrent) {
this.footerEffortCurrent.setText(
this.getThinkingLevelLabel(tab.thinkingLevel ?? "")
);
}
}
}
private resetActiveRenderState(): void {
this.currentAssistantMsg = null;
this.currentTextBlock = null;
this.currentThinkingBlock = null;
this.currentThinkingContent = null;
this.currentBlockRawText = "";
}
// ─── Event Handling ───────────────────────────────────────────────────
private getQueueTotal(event: RpcEvent): number {
const steering = event.steering as string[] | undefined;
const followUp = event.followUp as string[] | undefined;
return (steering?.length || 0) + (followUp?.length || 0);
}
private recordTabRuntimeState(tab: ChatTab, event: RpcEvent): void {
switch (event.type) {
case "agent_start":
tab.isStreaming = true;
break;
case "agent_end":
tab.isStreaming = false;
break;
case "queue_update":
tab.queueCount = this.getQueueTotal(event);
break;
case "thinking_level_changed":
case "model_changed":
// Authoritative state — pull full state from Pi to reconcile.
// The level / model fields on this event are advisory; getState()
// is the single source of truth for clamp results.
void this.syncTabStateFromPi(tab);
break;
}
}
private renderActiveTabRuntimeStatus(): void {
const tab = this.activeTab;
if (!tab) {
this.setStatus("✅ Ready", "ok");
return;
}
const queueCount = tab.queueCount || 0;
if (queueCount > 0) {
this.setStatus(`📋 ${queueCount} queued message(s)`, "thinking");
} else if (tab.isStreaming) {
this.setStatus("🤔 Thinking...", "thinking");
} else {
this.setStatus("✅ Ready", "ok");
}
}
private handleEvent(event: RpcEvent): void {
switch (event.type) {
case "agent_start":
this.isStreaming = true;
if (this.activeTab) this.activeTab.isStreaming = true;
this.startSpeedIndicator();
this.updateButtons();
this.setStatus("🤔 Thinking...", "thinking");
break;
case "agent_end":
this.isStreaming = false;
if (this.activeTab) this.activeTab.isStreaming = false;
this.currentAssistantMsg = null;
this.currentTextBlock = null;
this.currentThinkingBlock = null;
this.currentThinkingContent = null;
this.stopSpeedIndicator();
this.updateButtons();
this.renderActiveTabRuntimeStatus();
void this.refreshStateDisplay();
void this.maybeAutoContinueSmartReview();
break;
case "message_start":
this.handleMessageStart(event);
break;
case "message_update":
this.handleMessageUpdate(event);
break;
case "message_end":
this.handleMessageEnd(event);
break;
case "tool_execution_start":
this.handleToolStart(event);
break;
case "tool_execution_update":
this.handleToolUpdate(event);
break;
case "tool_execution_end":
this.handleToolEnd(event);
break;
case "turn_start":
this.setStatus("🔄 Processing turn...", "thinking");
break;
case "turn_end":
this.renderActiveTabRuntimeStatus();
break;
case "queue_update":
this.handleQueueUpdate(event);
break;
case "thinking_level_changed":
case "model_changed":
// Authority lives in Pi. recordTabRuntimeState() will fire
// syncTabStateFromPi() to reconcile this tab's state.
if (this.activeTab) {
this.recordTabRuntimeState(this.activeTab, event);
}
break;
case "compaction_start":
this.setStatus("📦 Compacting context...", "thinking");
break;
case "compaction_end":
this.compactedContextActive = !event.aborted;
this.setStatus("✅ Compaction complete", "ok");
break;
case "extension_ui_request":
// Handle extension UI requests from pi extensions
this.handleExtensionUIRequest(event);
break;
default:
// Unknown event, log for debugging
console.log("[pi-agent] Unhandled event:", event.type, event);
}
}
private handleMessageStart(event: RpcEvent): void {
const message = event.message as {
role: string;
content?: string | MessageContent[];
};
if (!message) return;
if (message.role === "user") {
const content =
typeof message.content === "string"
? message.content
: message.content
?.map((c) => c.text || c.thinking || "")
.join("") || "";
const rendered = this.addMessage("user", this.stripRecentContextGuard(content));
// Attach any images that were sent with this message to the bubble.
if (this.pendingUserImages.length > 0) {
this.renderUserMessageImages(rendered, this.pendingUserImages);
this.pendingUserImages = [];
}
} else if (message.role === "assistant") {
this.currentAssistantMsg = this.addMessage("assistant", "");
this.currentTextBlock = null;
this.currentThinkingBlock = null;
this.currentThinkingContent = null;
this.currentRawText = "";
this.currentBlockRawText = "";
this.lastRenderTime = 0;
if (this.renderTimeout) {
window.clearTimeout(this.renderTimeout);
this.renderTimeout = null;
}
this.scrollToBottom(true, true);
} else if (message.role === "toolResult") {
// Tool results are handled by tool_execution_end
}
}
private ensureAssistantStreamMessage(): RenderedMessage {
if (!this.currentAssistantMsg) {
this.currentAssistantMsg = this.addMessage("assistant", "");
this.currentTextBlock = null;
this.currentThinkingBlock = null;
this.currentThinkingContent = null;
this.currentRawText = "";
this.currentBlockRawText = "";
this.lastRenderTime = 0;
}
return this.currentAssistantMsg;
}
private handleMessageUpdate(event: RpcEvent): void {
const delta = event.assistantMessageEvent as AssistantMessageEvent;
if (!delta) return;
switch (delta.type) {
case "text_start":
this.currentBlockRawText = "";
this.currentTextBlock = null;
this.streamingTextEl = null;
this.streamingCursorEl = null;
break;
case "text_delta": {
const message = this.ensureAssistantStreamMessage();
if (!this.currentTextBlock) {
const usePretty = this.shouldUsePrettyStreaming(0);
this.currentTextBlock =
message.contentEl.createDiv(
usePretty
? "pi-agent-text-block markdown-preview-view markdown-rendered"
: "pi-agent-text-block pi-agent-streaming-block"
);
if (!usePretty) {
this.streamingTextEl = this.currentTextBlock.createDiv(
"pi-agent-streaming-text"
);
this.streamingCursorEl = this.currentTextBlock.createSpan(
"pi-agent-streaming-cursor"
);
}
}
const deltaText = delta.delta || "";
this.currentBlockRawText += deltaText;
this.currentRawText += deltaText;
this.addSpeedDelta(deltaText);
this.currentTextBlock.setAttribute("data-stream-raw", this.currentBlockRawText);
const usePretty = this.shouldUsePrettyStreaming(this.currentBlockRawText.length);
if (usePretty) {
this.throttleRender(this.currentBlockRawText, this.currentTextBlock);
} else {
if (!this.currentTextBlock.classList.contains("pi-agent-streaming-block")) {
this.convertCurrentTextBlockToFastStreaming();
}
this.appendStreamingDelta(this.currentBlockRawText, deltaText);
}
break;
}
case "thinking_start":
if (this.plugin.settings.showThinking) {
const message = this.ensureAssistantStreamMessage();
this.thinkingStartedAt = Date.now();
this.currentThinkingBlock =
message.contentEl.createDiv(
"pi-agent-thinking-block"
);
const header = this.currentThinkingBlock.createDiv(
"pi-agent-thinking-header"
);
const iconSpan = header.createSpan("pi-agent-thinking-icon");
setIcon(iconSpan, "brain");
const textSpan = header.createSpan("pi-agent-thinking-text");
textSpan.setText(" Thinking (1s)...");
this.currentThinkingContent =
this.currentThinkingBlock.createDiv(
"pi-agent-thinking-content"
);
const block = this.currentThinkingBlock;
header.onclick = () => {
block.toggleClass("is-collapsed", !block.hasClass("is-collapsed"));
};
if (this.thinkingTimer) {
window.clearInterval(this.thinkingTimer);
}
this.thinkingTimer = window.setInterval(() => {
const elapsed = this.thinkingStartedAt
? Math.max(1, Math.round((Date.now() - this.thinkingStartedAt) / 1000))
: 1;
const textSpan = header.querySelector(".pi-agent-thinking-text");
if (textSpan) {
textSpan.setText(` Thinking (${elapsed}s)...`);
}
}, 1000);
}
break;
case "thinking_delta":
if (this.currentThinkingContent) {
const shouldStickToBottom = this.isNearBottom();
this.currentThinkingContent.appendText(delta.delta || "");
if (shouldStickToBottom) this.scrollToBottom(true, true);
}
break;
case "thinking_end":
if (this.thinkingTimer) {
window.clearInterval(this.thinkingTimer);
this.thinkingTimer = null;
}
if (this.currentThinkingBlock) {
this.currentThinkingBlock.addClass("is-collapsed");
const header =
this.currentThinkingBlock.querySelector(
".pi-agent-thinking-header"
);
if (header) {
const elapsed = this.thinkingStartedAt
? Math.max(1, Math.round((Date.now() - this.thinkingStartedAt) / 1000))
: 0;
const textSpan = header.querySelector(".pi-agent-thinking-text");
if (textSpan) {
textSpan.setText(elapsed > 0 ? ` Thought for ${elapsed}s` : " Thought");
}
}
}
break;
case "toolcall_start":
// Tool call started - will be fleshed out in tool_execution events
break;
case "done":
break;
case "error": {
const message = this.ensureAssistantStreamMessage();
message.contentEl.createDiv(
"pi-agent-error-block"
).textContent = `⚠️ Error: ${delta.reason || "Unknown error"}`;
break;
}
}
}
private handleMessageEnd(event: RpcEvent): void {
if (this.thinkingTimer) {
window.clearInterval(this.thinkingTimer);
this.thinkingTimer = null;
}
if (this.renderTimeout) {
window.clearTimeout(this.renderTimeout);
this.renderTimeout = null;
}
if (this.currentAssistantMsg) {
this.currentAssistantMsg.el.setAttribute("data-raw-content", this.currentRawText);
const shouldStickToBottom = this.isNearBottom();
const renderPromises: Promise<unknown>[] = [];
// Finalize all streaming text blocks: replace the cheap <pre>-style buffer
// with a single full MarkdownRenderer pass.
const streamingBlocks =
this.currentAssistantMsg.contentEl.querySelectorAll(
".pi-agent-text-block"
);
streamingBlocks.forEach((textBlock: any) => {
const pre = textBlock.querySelector(
".pi-agent-streaming-text"
) as HTMLElement | null;
const raw = textBlock.getAttribute("data-stream-raw") || pre?.textContent || "";
if (raw.trim().length > 0) {
// Swap to a real markdown block.
textBlock.classList.remove("pi-agent-streaming-block");
textBlock.classList.add(
"markdown-preview-view",
"markdown-rendered"
);
textBlock.empty();
renderPromises.push(MarkdownRenderer.render(
this.app,
this.normalizeAssistantMarkdown(raw),
textBlock as HTMLElement,
"",
this
));
} else {
textBlock.remove();
}
});
this.streamingTextEl = null;
this.streamingCursorEl = null;
// Inline option chips: when the AI ends its message with a list of
// numbered/lettered options followed by a question, render them as
// clickable chips that fill the input. The user can also type freely.
const parsed = this.parseOptionsFromMessage(this.currentRawText);
if (parsed && parsed.options.length >= 2) {
this.renderOptionChips(this.currentAssistantMsg, parsed.options, parsed.isQuestion);
}
this.finalizeAssistantMessageVisibility(this.currentAssistantMsg);
if (shouldStickToBottom) {
this.scrollToBottom(true, true);
void Promise.all(renderPromises).then(() => this.scrollToBottom(true, true));
}
} else {
this.scrollToBottom();
}
}
private parseOptionsFromMessage(
text: string
): { options: string[]; isQuestion: boolean } | null {
if (!text) return null;
const lines = text.split("\n").map((l) => l.trim());
// 只识别显式编号1. / 2) / a. / 一、 等);不再把普通 bullet- * •)
// 误判为选项,避免回复里随手列点 → 跳出快速选项。
const optionRe = /^(?:\d+[.)]|[一二三四五六七八九十]+[、.)]|[a-zA-Z][.)])\s+(.+)$/;
// 找到所有连续选项块及位置(不止一个,可能存在历史/总结 + 提问)
type Block = { startIdx: number; endIdx: number; options: string[] };
const blocks: Block[] = [];
let cur: Block | null = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === "") {
if (cur && cur.options.length >= 2) blocks.push(cur);
cur = null;
continue;
}
const m = line.match(optionRe);
if (m) {
if (!cur) cur = { startIdx: i, endIdx: i, options: [] };
cur.endIdx = i;
cur.options.push(m[1].trim());
} else {
if (cur && cur.options.length >= 2) blocks.push(cur);
cur = null;
}
}
if (cur && cur.options.length >= 2) blocks.push(cur);
if (blocks.length === 0) return null;
const last = blocks[blocks.length - 1];
// 要求:选项块后面**紧跟**的下一行是非空问题。
// - 如果列表后面还有别的内容(其他段、表格、列表),不认为是在问选哪个
// - 问题不能是 yes/no不要 `要...吗` 模式),必须真的是在问选哪个
let nextLine: string | null = null;
for (let i = last.endIdx + 1; i < lines.length; i++) {
if (lines[i] !== "") {
nextLine = lines[i];
break;
}
}
if (nextLine === null) return null;
const isSelectionQuestion =
/[?]\s*$/.test(nextLine) || // 以 ? / 结尾(开放或选择问句)
/请选择|请告诉我|选哪个|用哪个|choose|pick|select/i.test(nextLine); // 显式选择短语
if (!isSelectionQuestion) return null;
return { options: last.options.slice(0, 8), isQuestion: true };
}
private renderOptionChips(
message: RenderedMessage,
options: string[],
isQuestion: boolean
): void {
if (message.contentEl.querySelector(".pi-agent-option-chips")) return;
const isZh = this.plugin.settings.language === "zh";
const wrap = message.contentEl.createDiv("pi-agent-option-chips");
const label = wrap.createDiv("pi-agent-option-chips-label");
label.setText(
isQuestion
? isZh
? "快捷选项(也可直接在下方输入框自行回复):"
: "Quick options (or just type your answer in the input below):"
: isZh
? "快捷选项(也可直接在下方输入框自行回复):"
: "Quick options (or just type your answer in the input below):"
);
const selected = new Set<string>();
const chipEls: HTMLElement[] = [];
for (const opt of options) {
const chip = wrap.createEl("button", {
text: opt,
cls: "pi-agent-option-chip",
attr: { type: "button" },
});
chipEls.push(chip);
chip.onclick = () => {
if (selected.has(opt)) {
selected.delete(opt);
chip.removeClass("is-selected");
} else {
selected.add(opt);
chip.addClass("is-selected");
}
updateSubmit();
};
}
const submit = wrap.createEl("button", {
text: isZh ? "提交 (0)" : "Submit (0)",
cls: "pi-agent-option-submit is-disabled",
attr: { type: "button" },
});
submit.onclick = () => {
if (selected.size === 0) return;
const text = Array.from(selected).join("\n");
this.setInputText(text);
this.inputEl?.focus();
// Trigger send after the input is set.
this.runAsync(() => this.sendMessage());
};
const customBtn = wrap.createEl("button", {
text: isZh ? "✎ 自行输入" : "✎ Type your own",
cls: "pi-agent-option-custom",
attr: { type: "button" },
});
customBtn.onclick = () => {
this.inputEl?.focus();
this.inputEl?.scrollIntoView({ block: "center", behavior: "smooth" });
};
const clearBtn = wrap.createEl("button", {
text: isZh ? "清空" : "Clear",
cls: "pi-agent-option-clear",
attr: { type: "button" },
});
clearBtn.onclick = () => {
selected.clear();
chipEls.forEach((c) => c.removeClass("is-selected"));
updateSubmit();
};
const updateSubmit = () => {
const count = selected.size;
submit.setText(
isZh ? `提交 (${count})` : `Submit (${count})`
);
submit.toggleClass("is-disabled", count === 0);
};
}
private handleToolStart(event: RpcEvent): void {
const toolName = event.toolName as string;
const toolCallId = event.toolCallId as string;
const args = event.args as Record<string, unknown> | undefined;
if (!this.currentAssistantMsg) {
this.currentAssistantMsg = this.addMessage("assistant", "");
}
const toolBlock = this.currentAssistantMsg.contentEl.createDiv(
"pi-agent-tool-block"
);
const header = toolBlock.createDiv("pi-agent-tool-header");
header.createSpan({ text: this.getToolIcon(toolName), cls: "pi-agent-tool-icon" });
header.createSpan({ text: this.toTitleCase(toolName), cls: "pi-agent-tool-name" });
if (args) {
const argsText = this.formatToolArgs(toolName, args);
if (argsText) {
const argsEl = header.createSpan({ text: argsText, cls: "pi-agent-tool-args" });
const path = (typeof args.path === "string" ? args.path : "") ||
(typeof args.TargetFile === "string" ? args.TargetFile : "") ||
(typeof args.target === "string" ? args.target : "");
if (path) {
argsEl.addClass("is-clickable");
argsEl.setAttribute("title", `${path} (Click to open)`);
argsEl.onclick = (event) => {
event.stopPropagation();
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
void this.app.workspace.getLeaf(false).openFile(file).catch((err: unknown) => {
console.error("[pimate] open file failed", err);
});
}
};
} else if (toolName === "bash" && args.command) {
argsEl.addClass("is-clickable");
const fullCmd = args.command as string;
const isZh = this.plugin.settings.language === "zh";
argsEl.setAttribute("title", `${fullCmd} (Click to copy)`);
argsEl.onclick = (event) => {
event.stopPropagation();
void navigator.clipboard.writeText(fullCmd).then(() => {
new Notice(isZh ? "命令已复制到剪贴板" : "Command copied to clipboard");
}).catch((err: unknown) => {
new Notice(`Failed to copy: ${err}`);
});
};
}
}
}
header.createSpan({ text: "...", cls: "pi-agent-tool-close is-loading" });
const outputEl = toolBlock.createDiv("pi-agent-tool-output");
header.onclick = () => outputEl.toggleClass("is-visible", !outputEl.hasClass("is-visible"));
// Store reference for updates
toolBlock.setAttribute("data-tool-id", toolCallId);
(toolBlock as any).__outputEl = outputEl;
(toolBlock as any).__startedAt = Date.now();
}
private shouldWarnToolExecution(toolName: string, args?: Record<string, unknown>): boolean {
if (["write", "edit"].includes(toolName)) return true;
if (toolName === "bash") {
const command = String(args?.command || "");
return this.isDangerousBashCommand(command);
}
return false;
}
private handleToolUpdate(event: RpcEvent): void {
const toolCallId = event.toolCallId as string;
const partialResult = event.partialResult as
| { content?: Array<{ type: string; text: string }> }
| undefined;
const toolBlock = this.chatContainer?.querySelector(
`[data-tool-id="${toolCallId}"]`
) as HTMLElement | null;
if (toolBlock && partialResult?.content) {
const outputEl = (toolBlock as any).__outputEl as HTMLElement;
if (outputEl) {
const text = partialResult.content.map((c) => c.text).join("");
const preview = text.trim();
outputEl.setText(preview.length > 1200 ? preview.slice(0, 1200) + "\n…" : preview);
outputEl.toggleClass("is-visible", preview.length > 0);
this.scrollToBottom();
}
}
}
private handleToolEnd(event: RpcEvent): void {
const toolCallId = event.toolCallId as string;
const result = event.result as
| { content?: Array<{ type: string; text: string }>; isError?: boolean }
| undefined;
const isError = event.isError as boolean;
const toolBlock = this.chatContainer?.querySelector(
`[data-tool-id="${toolCallId}"]`
) as HTMLElement | null;
if (toolBlock) {
toolBlock.addClass(isError ? "is-error" : "is-success");
const closeEl = toolBlock.querySelector(".pi-agent-tool-close") as HTMLElement | null;
if (closeEl) {
closeEl.removeClass("is-loading");
closeEl.textContent = isError ? "×" : "✓";
}
const details = (event.result as any)?.details;
const diffText = this.getDiffText(details);
const stats = this.getDiffStats(details);
if (stats) {
const closeEl = toolBlock.querySelector(".pi-agent-tool-close");
const statEl = activeDocument.createElement("span");
statEl.className = "pi-agent-tool-diff";
const addedEl = activeDocument.createElement("span");
addedEl.className = "pi-agent-tool-add";
addedEl.textContent = `+${stats.added}`;
const removedEl = activeDocument.createElement("span");
removedEl.className = "pi-agent-tool-remove";
removedEl.textContent = `${stats.removed}`;
statEl.append(addedEl, removedEl);
closeEl?.parentElement?.insertBefore(statEl, closeEl);
}
const outputEl = (toolBlock as any).__outputEl as HTMLElement;
if (outputEl && result?.content) {
const text = result.content.map((c) => c.text).join("").trim();
outputEl.empty();
if (isError) {
outputEl.createSpan({ text, cls: "pi-agent-tool-error" });
outputEl.addClass("is-visible");
} else if (diffText && ["edit", "write"].includes(event.toolName as string)) {
this.renderDiffOutput(outputEl, diffText);
} else if (text && ["bash", "grep", "find", "ls"].includes(event.toolName as string)) {
const displayText =
text.length > 1600 ? text.slice(0, 1600) + "\n…" : text;
const pre = outputEl.createEl("pre");
pre.setText(displayText);
this.renderDetectedFiles(outputEl, text);
if (isError) {
outputEl.addClass("is-visible");
}
}
}
}
}
private handleQueueUpdate(event: RpcEvent): void {
const total = this.getQueueTotal(event);
if (this.activeTab) this.activeTab.queueCount = total;
this.renderActiveTabRuntimeStatus();
}
private handleExtensionUIRequest(event: RpcEvent): void {
const id = event.id as string;
const method = event.method as string;
if (method === "confirm") {
const title = event.title as string;
const message = event.message as string;
new PiAgentConfirmModal(this.app, title, message, (confirmed) => {
this.client?.sendUIResponse(id, { confirmed });
}).open();
} else if (method === "select") {
const title = event.title as string;
const options = event.options as string[];
new PiAgentSelectModal(this.app, title, options, (value) => {
if (value) this.client?.sendUIResponse(id, { value });
else this.client?.sendUIResponse(id, { cancelled: true });
}).open();
} else if (method === "input") {
const title = event.title as string;
const placeholder = event.placeholder as string;
new PiAgentInputModal(this.app, title, placeholder || "", (value) => {
if (value !== null) this.client?.sendUIResponse(id, { value });
else this.client?.sendUIResponse(id, { cancelled: true });
}).open();
} else if (method === "editor") {
// Open in a new note for editing
const title = event.title as string;
const prefill = event.prefill as string;
this.openEditorModal(id, title, prefill);
} else {
// notify, setStatus, setWidget, setTitle, set_editor_text are fire-and-forget
if (method === "notify") {
new Notice(
`🔔 ${(event.message as string) || ""}`
);
} else if (method === "setWidget") {
console.log("[pimate] setWidget event received:", event);
const widgetKey = event.widgetKey as string;
const widgetLines = event.widgetLines as string[] | undefined;
this.updateWidget(widgetKey, widgetLines);
}
}
}
private async openEditorModal(
id: string,
title: string,
prefill: string
): Promise<void> {
const value = await new Promise<string | null>((resolve) => {
new PiAgentEditorModal(this.app, title, prefill || "", resolve).open();
});
if (value === null) {
this.client?.sendUIResponse(id, { cancelled: true });
} else {
this.client?.sendUIResponse(id, { value });
}
}
// ─── UI Rendering ─────────────────────────────────────────────────────
private addMessage(role: string, content: string): RenderedMessage {
if (!this.chatContainer) {
throw new Error("Chat container not initialized");
}
this.clearEmptyState();
const msgEl = this.chatContainer.createDiv(
`pi-agent-message pi-agent-message-${role}`
);
// Role badge
const badge = msgEl.createDiv("pi-agent-message-badge");
switch (role) {
case "user":
badge.setText("👤 You");
break;
case "assistant":
badge.setText("π Pi");
break;
case "system":
badge.setText(" System");
break;
default:
badge.setText(role);
}
// Content
const contentEl = msgEl.createDiv("pi-agent-message-content");
if (role === "user" && content) {
const visibleContent = this.stripRecentContextGuard(content);
contentEl.createSpan({ text: visibleContent });
msgEl.setAttribute("data-raw-content", visibleContent);
}
// Add floating hover actions
if (role === "user" || role === "assistant") {
const actionsEl = msgEl.createDiv("pi-agent-msg-actions");
// 1. Copy button
const copyBtn = actionsEl.createEl("button", {
cls: "pi-agent-action-btn",
attr: { title: "Copy message" },
});
copyBtn.setText("📋");
copyBtn.onclick = (e) => {
e.stopPropagation();
const rawContent = msgEl.getAttribute("data-raw-content") || msgEl.textContent || "";
void navigator.clipboard.writeText(rawContent).then(() => {
new Notice("Copied to clipboard");
}).catch((err: unknown) => {
console.error("[pimate] copy message failed", err);
});
};
// 2. Insert to active editor (assistant only)
if (role === "assistant") {
const insertBtn = actionsEl.createEl("button", {
cls: "pi-agent-action-btn",
attr: { title: "Insert into active note" },
});
insertBtn.setText("↵");
insertBtn.onclick = (e) => {
e.stopPropagation();
const rawContent = msgEl.getAttribute("data-raw-content") || msgEl.textContent || "";
const activeMarkdown = this.app.workspace.getActiveViewOfType(MarkdownView);
const editor = activeMarkdown?.editor;
if (!editor) {
new Notice("Please open a markdown note first");
return;
}
editor.replaceSelection(rawContent);
new Notice("Inserted response into note");
};
}
// 3. Edit / Reuse (user only)
if (role === "user") {
const reuseBtn = actionsEl.createEl("button", {
cls: "pi-agent-action-btn",
attr: { title: "Reuse and edit message" },
});
reuseBtn.setText("✏️");
reuseBtn.onclick = (e) => {
e.stopPropagation();
const rawContent = msgEl.getAttribute("data-raw-content") || "";
this.setInputText(rawContent);
};
// Double click card to auto fill
msgEl.ondblclick = (e) => {
e.stopPropagation();
const rawContent = msgEl.getAttribute("data-raw-content") || "";
this.setInputText(rawContent);
};
}
}
const rendered: RenderedMessage = {
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2)}`,
role,
el: msgEl,
contentEl,
};
this.renderedMessages.push(rendered);
// Limit displayed messages
const maxDisplay = this.plugin.settings.maxHistoryDisplay;
while (this.renderedMessages.length > maxDisplay) {
const oldest = this.renderedMessages.shift();
if (oldest) oldest.el.remove();
}
this.scrollToBottom(true, true);
return rendered;
}
private addSystemMessage(text: string): void {
if (!this.chatContainer) return;
this.clearEmptyState();
const el = this.chatContainer.createDiv("pi-agent-system-msg");
el.setText(text);
this.scrollToBottom(true, true);
}
private addCompactionSummaryMessage(
summary: string,
tokensBefore?: number,
title = "Context compacted"
): void {
if (!this.chatContainer) return;
this.clearEmptyState();
const wrap = this.chatContainer.createDiv("pi-agent-compaction-summary");
const header = wrap.createDiv("pi-agent-compaction-header");
header.setText(
tokensBefore
? `📦 ${title} · ${tokensBefore.toLocaleString()} tokens summarized`
: `📦 ${title}`
);
if (summary && summary.trim()) {
const body = wrap.createDiv("pi-agent-compaction-body markdown-preview-view markdown-rendered");
void MarkdownRenderer.render(this.app, summary, body, "", this);
}
this.scrollToBottom(true, true);
}
private renderEmptyState(): void {
if (!this.chatContainer || this.chatContainer.querySelector(".pi-agent-empty-state")) return;
const empty = this.chatContainer.createDiv("pi-agent-empty-state");
empty.createDiv({ text: "π", cls: "pi-agent-empty-logo" });
empty.createDiv({ text: "Pimate", cls: "pi-agent-empty-title" });
empty.createDiv({ text: "Ask Pi to read, write, explain, or refactor your vault.", cls: "pi-agent-empty-subtitle" });
const prompts = empty.createDiv("pi-agent-empty-prompts");
for (const prompt of [
"总结当前笔记",
"把选中内容改得更克制",
"搜索这个 vault 里的相关内容",
"解释我粘贴的截图",
]) {
const chip = prompts.createSpan({ text: prompt, cls: "pi-agent-empty-prompt" });
chip.onclick = () => this.setInputText(prompt);
}
}
private clearEmptyState(): void {
this.chatContainer?.querySelector(".pi-agent-empty-state")?.remove();
}
private startSpeedIndicator(): void {
if (this.speedHideTimer) {
window.clearTimeout(this.speedHideTimer);
this.speedHideTimer = null;
}
if (this.speedTimer) {
window.clearInterval(this.speedTimer);
this.speedTimer = null;
}
const tab = this.activeTab;
if (tab) {
tab.speedStartedAt = null;
tab.speedEstimatedTokens = 0;
tab.speedHideAt = null;
}
this.speedStartedAt = null;
this.speedEstimatedTokens = 0;
if (this.speedEl) {
this.speedEl.addClass("pi-agent-hidden");
this.speedEl.setText("…");
}
}
private estimateTokenCount(text: string): number {
if (!text) return 0;
let cjk = 0;
let other = 0;
for (const ch of text) {
if (/\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}/u.test(ch)) cjk++;
else if (!/\s/.test(ch)) other++;
}
return cjk + other / 4;
}
private addSpeedDelta(text: string): void {
if (!text) return;
const estimatedTokens = this.estimateTokenCount(text);
const tab = this.activeTab;
if (tab) {
if (!tab.speedStartedAt) tab.speedStartedAt = Date.now();
tab.speedEstimatedTokens = (tab.speedEstimatedTokens || 0) + estimatedTokens;
tab.speedHideAt = null;
}
if (!this.speedStartedAt) this.speedStartedAt = Date.now();
this.speedEstimatedTokens += estimatedTokens;
if (this.speedEl) this.speedEl.removeClass("pi-agent-hidden");
if (!this.speedTimer) {
this.speedTimer = window.setInterval(() => this.updateSpeedIndicator(false), 750);
}
this.updateSpeedIndicator(false);
}
private updateSpeedIndicator(final: boolean): void {
if (!this.speedEl) return;
const tab = this.activeTab;
const startedAt = tab?.speedStartedAt ?? this.speedStartedAt;
const tokens = tab?.speedEstimatedTokens ?? this.speedEstimatedTokens;
if (!startedAt || tokens <= 0) {
return;
}
this.speedEl.removeClass("pi-agent-hidden");
const elapsedSec = Math.max(0.1, (Date.now() - startedAt) / 1000);
const rate = tokens / elapsedSec;
const rounded = rate >= 10 ? Math.round(rate) : Number(rate.toFixed(1));
const prefix = final ? "" : "~";
this.speedEl.setText(`${prefix}${rounded} tok/s`);
this.speedEl.setAttribute(
"title",
`Estimated output speed: ${rounded} tok/s · ${Math.round(tokens)} tokens · ${elapsedSec.toFixed(1)}s`
);
}
private stopSpeedIndicator(): void {
if (this.speedTimer) {
window.clearInterval(this.speedTimer);
this.speedTimer = null;
}
const tab = this.activeTab;
if (tab) tab.speedHideAt = Date.now() + 8000;
this.updateSpeedIndicator(true);
if (this.speedHideTimer) window.clearTimeout(this.speedHideTimer);
this.speedHideTimer = window.setTimeout(() => {
this.speedEl?.addClass("pi-agent-hidden");
this.speedHideTimer = null;
}, 8000);
}
private renderActiveTabSpeed(): void {
if (!this.speedEl) return;
if (this.speedTimer) {
window.clearInterval(this.speedTimer);
this.speedTimer = null;
}
if (this.speedHideTimer) {
window.clearTimeout(this.speedHideTimer);
this.speedHideTimer = null;
}
const tab = this.activeTab;
if (!tab) {
this.speedEl.addClass("pi-agent-hidden");
return;
}
const startedAt = tab.speedStartedAt || null;
const tokens = tab.speedEstimatedTokens || 0;
this.speedStartedAt = startedAt;
this.speedEstimatedTokens = tokens;
if (!startedAt || tokens <= 0) {
this.speedEl.addClass("pi-agent-hidden");
return;
}
this.speedEl.removeClass("pi-agent-hidden");
this.updateSpeedIndicator(true);
if (tab.isStreaming) {
this.speedTimer = window.setInterval(() => this.updateSpeedIndicator(false), 750);
} else {
const hideAt = tab.speedHideAt || 0;
if (hideAt <= Date.now()) {
this.speedEl.addClass("pi-agent-hidden");
return;
}
this.speedHideTimer = window.setTimeout(() => {
if (!this.activeTab?.isStreaming) {
this.speedEl?.addClass("pi-agent-hidden");
}
this.speedHideTimer = null;
}, hideAt - Date.now());
}
}
private renderActiveTabModelAndEffort(): void {
const tab = this.activeTab;
if (!tab) return;
const provider = tab.modelProvider || this.plugin.settings.provider || "";
const modelId = tab.modelId || this.plugin.settings.modelId || "";
const level = tab.thinkingLevel ?? this.plugin.settings.thinkingLevel ?? "";
this.updateModelDisplay(provider, modelId);
if (this.footerEffortCurrent) {
this.footerEffortCurrent.setText(this.getThinkingLevelLabel(level));
}
}
private isNearBottom(threshold = 80): boolean {
if (!this.chatContainer) return true;
const scrollOffset =
this.chatContainer.scrollHeight -
this.chatContainer.scrollTop -
this.chatContainer.clientHeight;
return scrollOffset <= threshold;
}
private scrollToBottom(immediate = true, force = false): void {
if (!this.chatContainer || !this.plugin.settings.autoScroll) return;
if (!force) {
// Smart Auto-Scroll Lock: if user scrolled up more than 50px, do not hijack the view.
const scrollOffset = this.chatContainer.scrollHeight - this.chatContainer.scrollTop - this.chatContainer.clientHeight;
if (scrollOffset >= 50) {
return;
}
}
if (immediate) {
this.chatContainer.scrollTop = this.chatContainer.scrollHeight;
}
// Compensation delays for dynamic reflow
window.setTimeout(() => {
if (this.chatContainer) {
if (!force) {
const scrollOffset = this.chatContainer.scrollHeight - this.chatContainer.scrollTop - this.chatContainer.clientHeight;
if (scrollOffset >= 50) return;
}
this.chatContainer.scrollTop = this.chatContainer.scrollHeight;
}
}, 50);
window.setTimeout(() => {
if (this.chatContainer) {
if (!force) {
const scrollOffset = this.chatContainer.scrollHeight - this.chatContainer.scrollTop - this.chatContainer.clientHeight;
if (scrollOffset >= 50) return;
}
this.chatContainer.scrollTop = this.chatContainer.scrollHeight;
}
}, 150);
}
private focusAdjacentMessage(direction: -1 | 1): void {
// Only navigate between USER messages, not assistant/system/tool messages.
const messages = Array.from(
this.chatContainer?.querySelectorAll(".pi-agent-message-user") || []
) as HTMLElement[];
if (messages.length === 0) return;
// Use viewport-relative position to find current message reliably.
// This is more stable than scroll-center calculation.
const containerRect = this.chatContainer?.getBoundingClientRect();
if (!containerRect) return;
const viewportCenterY = containerRect.top + containerRect.height / 2;
// Find the message whose vertical center is closest to viewport center.
let currentIndex = 0;
let minDist = Infinity;
messages.forEach((msg, i) => {
const msgRect = msg.getBoundingClientRect();
const msgCenter = msgRect.top + msgRect.height / 2;
const dist = Math.abs(msgCenter - viewportCenterY);
if (dist < minDist) {
minDist = dist;
currentIndex = i;
}
});
// Navigate: clamp to valid range.
const nextIndex = Math.max(0, Math.min(messages.length - 1, currentIndex + direction));
if (nextIndex === currentIndex) return; // At boundary, nothing to do.
messages[nextIndex]?.scrollIntoView({ block: "center", behavior: "smooth" });
}
/** Jump to the first or last USER message in the chat (used by the floating nav). */
private focusEdgeMessage(edge: "first" | "last"): void {
const messages = Array.from(
this.chatContainer?.querySelectorAll(".pi-agent-message-user") || []
) as HTMLElement[];
if (messages.length === 0) return;
const target = edge === "first" ? messages[0] : messages[messages.length - 1];
target?.scrollIntoView({ block: "start", behavior: "smooth" });
}
/**
* Show/hide the prev/next nav buttons based on how many user messages exist.
* Hide both when there's < 2 user messages (only one target, prev/next are no-ops).
*/
private setStatus(
text: string,
type: "ok" | "thinking" | "error" | "warning"
): void {
if (!this.statusBar) return;
this.statusBar.empty();
this.statusBar.removeAttribute("title");
this.statusBar.className = `pi-agent-status pi-agent-status-${type}`;
if (type === "ok") return;
if (type === "error") {
setIcon(this.statusBar, "alert-circle");
this.statusBar.setAttribute("title", text);
return;
}
if (type === "warning") {
setIcon(this.statusBar, "alert-triangle");
this.statusBar.setAttribute("title", text);
return;
}
if (type === "thinking") {
const lowerText = text.toLowerCase();
if (lowerText.includes("thinking")) {
setIcon(this.statusBar, "brain");
this.statusBar.setAttribute("title", "Thinking...");
} else if (lowerText.includes("compact")) {
setIcon(this.statusBar, "shrink");
this.statusBar.setAttribute("title", "Compacting memory...");
} else if (lowerText.includes("queue")) {
setIcon(this.statusBar, "list-ordered");
this.statusBar.setAttribute("title", text);
} else {
setIcon(this.statusBar, "loader-2");
this.statusBar.setAttribute("title", "Running...");
}
}
}
private renderDetectedFiles(outputEl: HTMLElement, text: string): void {
const pathRegex = /[a-zA-Z0-9_\-\/\\.]+\.[a-zA-Z]{2,10}/g;
const words = text.match(pathRegex) || [];
if (words.length === 0) return;
const uniqueFiles = new Set<TFile>();
for (const word of words) {
if (word.includes("node_modules") || word.includes(".git") || word.startsWith("http")) continue;
const base = this.getBasename(word);
if (!base || base.length < 4) continue;
const file = this.app.metadataCache.getFirstLinkpathDest(base, "");
if (file instanceof TFile) {
uniqueFiles.add(file);
}
}
if (uniqueFiles.size > 0) {
const chipsContainer = activeDocument.createElement("div");
chipsContainer.className = "pi-agent-detected-files";
chipsContainer.createSpan({ text: "Detected files: ", cls: "pi-agent-detected-label" });
uniqueFiles.forEach((file) => {
const chip = chipsContainer.createSpan({
text: file.name,
cls: "pi-agent-file-chip is-clickable",
attr: { title: `${file.path} (Click to open)` }
});
chip.onclick = (e) => {
e.stopPropagation();
void this.app.workspace.getLeaf(false).openFile(file).catch((err: unknown) => {
console.error("[pimate] open linked file failed", err);
});
};
});
outputEl.insertBefore(chipsContainer, outputEl.firstChild);
}
}
private updateWidget(widgetKey: string, lines: string[] | undefined): void {
if (!this.widgetEl) return;
try {
if (!lines || lines.length === 0) {
this.widgetEl.empty();
this.widgetEl.addClass("pi-agent-hidden");
return;
}
this.widgetEl.empty();
this.widgetEl.removeClass("pi-agent-hidden");
this.widgetEl.className = `pi-agent-widget pi-agent-widget-${widgetKey}`;
const titleLine = lines[0];
const contentLines = lines.slice(1);
const header = this.widgetEl.createDiv("pi-agent-widget-header");
const icon = header.createSpan("pi-agent-widget-icon");
try {
setIcon(icon, "list-todo");
} catch {
icon.setText("📋");
}
header.createSpan({ text: titleLine, cls: "pi-agent-widget-title" });
const listContainer = this.widgetEl.createDiv("pi-agent-widget-list");
let foundActive = false;
for (const line of contentLines) {
const item = listContainer.createDiv("pi-agent-widget-item");
let text = line.trim();
let status = "pending";
if (text.startsWith("✓")) {
status = "done";
text = text.slice(1).trim();
} else {
if (!foundActive) {
status = "active";
foundActive = true;
} else {
status = "pending";
}
if (text.startsWith("●")) {
text = text.slice(1).trim();
}
}
const iconEl = item.createSpan(`pi-agent-widget-item-icon pi-status-${status}`);
iconEl.setText(status === "done" ? "✓" : "●");
const textEl = item.createSpan(`pi-agent-widget-item-text pi-status-${status}`);
textEl.setText(text);
}
} catch (err) {
console.error("[pimate] updateWidget error:", err);
}
}
// ─── Actions ───────────────────────────────────────────────────────────
private showMoreMenu(event: MouseEvent): void {
const menu = new Menu();
const isZh = this.plugin.settings.language === "zh";
menu.addItem((item) =>
item
.setTitle(isZh ? "指令 / 技能" : "Commands / Skills")
.setIcon("terminal")
.onClick(() => this.runAsync(() => this.showCommandSelector()))
);
menu.addItem((item) =>
item
.setTitle(isZh ? "附加当前打开的笔记" : "Attach current open note")
.setIcon("file-plus")
.onClick(() => this.addCurrentFileContext())
);
menu.addItem((item) =>
item
.setTitle(isZh ? "附加文件管理器选中项" : "Attach file explorer selection")
.setIcon("list-plus")
.onClick(() => this.addExplorerSelectionContext())
);
menu.addItem((item) =>
item
.setTitle(isZh ? "插入最后一条回复" : "Insert last response")
.setIcon("pencil")
.onClick(() => this.insertLastAssistantIntoActiveNote())
);
const snippets = this.getParsedSnippets();
if (snippets.length > 0) {
menu.addSeparator();
for (const snippet of snippets.slice(0, 12)) {
const title = snippet.group ? `${snippet.group} / ${snippet.title}` : snippet.title;
const snippetLabel = isZh ? "片段" : "Snippet";
menu.addItem((item) =>
item
.setTitle(`${snippetLabel}: ${title.slice(0, 42)}${title.length > 42 ? "…" : ""}`)
.setIcon("text-cursor-input")
.onClick(() => this.appendInputText(this.expandSnippet(snippet.content)))
);
}
}
menu.addSeparator();
menu.addItem((item) =>
item
.setTitle(isZh ? "恢复会话..." : "Resume session…")
.setIcon("history")
.onClick(() => this.showResumeSelector())
);
menu.addItem((item) =>
item
.setTitle(isZh ? "从提示词分叉..." : "Fork from prompt…")
.setIcon("git-fork")
.onClick(() => this.showForkSelector())
);
menu.addItem((item) =>
item
.setTitle(isZh ? "克隆当前分支" : "Clone current branch")
.setIcon("copy")
.onClick(() => this.cloneCurrentBranch())
);
menu.addSeparator();
menu.addItem((item) =>
item
.setTitle(isZh ? "压缩上下文" : "Compact context")
.setIcon("archive")
.onClick(() => this.runAsync(() => this.compactSession()))
);
menu.addItem((item) =>
item
.setTitle(isZh ? "导出 HTML" : "Export HTML")
.setIcon("download")
.onClick(() => this.runAsync(() => this.exportSessionHtml()))
);
menu.addItem((item) =>
item
.setTitle(isZh ? "会话统计" : "Session stats")
.setIcon("bar-chart-2")
.onClick(() => this.runAsync(() => this.showStats()))
);
menu.addItem((item) =>
item
.setTitle(isZh ? "Token 用量..." : "Token Usage…")
.setIcon("bar-chart-3")
.onClick(() => this.showUsageStats())
);
menu.addSeparator();
menu.addItem((item) =>
item
.setTitle(isZh ? "上一条消息" : "Previous message")
.setIcon("arrow-up")
.onClick(() => this.scrollToPreviousMessage())
);
menu.addItem((item) =>
item
.setTitle(isZh ? "下一条消息" : "Next message")
.setIcon("arrow-down")
.onClick(() => this.scrollToNextMessage())
);
menu.addItem((item) =>
item
.setTitle(isZh ? "切换最后一条工具输出" : "Toggle last tool output")
.setIcon("panel-bottom-close")
.onClick(() => this.toggleLastToolBlock())
);
menu.addItem((item) =>
item
.setTitle(isZh ? "跳到最后一次 diff" : "Jump to last diff")
.setIcon("git-compare")
.onClick(() => this.scrollToLastDiff())
);
menu.showAtMouseEvent(event);
}
private getParsedSnippets(): ParsedSnippet[] {
return (this.plugin.settings.snippets || [])
.map((line) => this.parseSnippet(line))
.filter((snippet): snippet is ParsedSnippet => Boolean(snippet?.content));
}
private parseSnippet(line: string): ParsedSnippet | null {
const raw = line.trim();
if (!raw) return null;
const [head, ...rest] = raw.split("::");
if (rest.length === 0) {
return {
title: raw.slice(0, 36),
content: raw,
};
}
const content = rest.join("::").trim();
if (!content) return null;
const parts = head.split("/").map((part) => part.trim()).filter(Boolean);
const title = parts.pop() || content.slice(0, 36);
const group = parts.join(" / ") || undefined;
return { title, group, content };
}
private expandSnippet(snippet: string): string {
const activeFile = this.app.workspace.getActiveFile();
const selection = this.contextItems.find((item) => item.type === "selection")?.value || "";
return snippet
.replace(/\{\{selection\}\}/g, selection)
.replace(/\{\{current_file\}\}/g, activeFile?.path || "")
.replace(/\{\{current_title\}\}/g, activeFile?.basename || "")
.replace(/\{\{date\}\}/g, new Date().toISOString().slice(0, 10));
}
private updateInputModeState(): void {
if (!this.inputEl) return;
const isBash = this.inputEl.value.trimStart().startsWith("!");
this.inputEl.toggleClass("is-bash-mode", isBash);
this.inputEl.setAttribute("placeholder", isBash ? "Bash mode — command will run locally" : "How can I help you today?");
this.statusBar?.toggleClass("is-bash-mode", isBash);
if (isBash && this.statusBar) this.statusBar.setText("Bash mode");
}
private async sendMessage(): Promise<void> {
if (!this.client || !this.inputEl) return;
const rawMessage = this.inputEl.value.trim();
const contextPrefix = this.buildContextPrefix();
const images = this.getImagePayloads();
const userMessage = rawMessage || (images.length ? "Please analyze the attached image(s)." : "");
const baseMessage = `${contextPrefix}${userMessage}`.trim();
if (!baseMessage && images.length === 0) return;
const message = this.applySystemPrompt(baseMessage);
this.maybeTitleActiveTab(rawMessage || message);
// Stash images so the user-message bubble (rendered when Pi echoes via
// message_start) can show the attached images at the top, like Claudian.
this.pendingUserImages = images.map((i) => ({ data: i.data, mimeType: i.mimeType }));
// Pi RPC emits the accepted user message via message_start.
// Do not render optimistically here, otherwise the message appears twice.
// Clear input
this.inputEl.value = "";
this.inputEl.setCssProps({ height: "auto" });
this.updateInputModeState();
this.clearContextItems();
// Reset smart-review counter for fresh user goals so the auto-continue
// loop only runs within the same goal.
this.smartReviewContinues = 0;
this.smartReviewOriginalGoal = rawMessage || message;
try {
if (message.startsWith("!")) {
this.smartReviewOriginalGoal = null;
await this.runBashMode(message);
} else if (this.isStreaming) {
// Queue as steer message
await this.client.steer(message, { images });
} else {
await this.client.prompt(message, { images });
}
} catch (err) {
this.addSystemMessage(
`❌ Failed to send: ${(err as Error).message}`
);
}
}
private applySystemPrompt(message: string): string {
if (message.startsWith("!")) return message;
const systemInstructions: string[] = [];
const systemPrompt = (this.plugin.settings.systemPrompt || "").trim();
if (systemPrompt) systemInstructions.push(systemPrompt);
const smartReviewPrompt = this.getSmartReviewPrompt();
if (smartReviewPrompt) systemInstructions.push(smartReviewPrompt);
if (systemInstructions.length === 0) return message;
return [
"System instruction for this Pimate turn:",
systemInstructions.join("\n\n"),
"",
"User request:",
message,
].join("\n");
} private getSmartReviewPrompt(): string {
if (this.plugin.settings.smartReviewEnabled !== true) return "";
const isZh = this.plugin.settings.language !== "en";
return isZh
? "智能审核已开启。任务完成后请明确回复“已完成”;若未完成,请简要说明还差什么。"
: "Smart review is on. When the task is complete, reply exactly with \"Done\". If not yet complete, briefly state what is still missing.";
}
// ─── Smart Review Auto-Continue Loop ───────────────────────────────
// Lightweight rule-based check against the last assistant text. We avoid an
// extra LLM call here to keep latency and cost low; upgrade to a judge
// later if we need richer semantics.
private shouldAutoContinueFromAssistantText(text: string): boolean {
const t = (text || "").trim();
if (!t) return false;
const lower = t.toLowerCase();
// Clear "done" markers — if any of these are present we trust the reply.
const doneMarkers = [
/已完成/,
/已经完成/,
/任务完成/,
/全部完成/,
/最终结果/,
/已经修复/,
/已经通过/,
/测试通过/,
/build (?:passes|succeeded|ok)/i,
/all (?:tests|checks?) pass/i,
/task (?:is )?(?:complete|done|finished)/i,
/no further (?:changes?|actions?) (?:needed|required)/i,
/lgtm/i,
];
if (doneMarkers.some((r) => r.test(t))) return false;
// Incomplete markers — if any are present, continue.
const continueMarkers = [
/我将继续/,
/接下来我会/,
/下一步我会/,
/现在去/,
/我去/,
/正在修复/,
/尚未完成/,
/还没有完成/,
/未完成/,
/还没修复/,
/需要继续/,
/需要进一步/,
/还需要/,
/仍然存在/,
/测试失败/,
/failed/i,
/will (?:now |then )?(?:continue|fix|verify|run|check|proceed)/i,
/next[, ]? i (?:will|'ll)/i,
/todo:/i,
/not (?:yet )?(?:complete|done|finished|fixed)/i,
/still (?:need|needs|failing|pending)/i,
];
if (continueMarkers.some((r) => r.test(t))) return true;
// Trailing ellipsis or trailing action hint often means the reply was cut
// off or the model is deferring action. Treat as continue.
if (/[。.…]{1,3}$/.test(t) && /(我|我们|i|we)\s*(?:将|会|'ll|will)/.test(lower)) {
return true;
}
return false;
}
private getSmartReviewMaxContinues(): number {
const raw = this.plugin.settings.smartReviewMaxContinues;
if (typeof raw !== "number" || isNaN(raw)) return 3;
return Math.max(1, Math.min(10, Math.floor(raw)));
}
private async maybeAutoContinueSmartReview(): Promise<void> {
if (this.plugin.settings.smartReviewEnabled !== true) return;
if (!this.client) return;
if (this.isStreaming) return;
if (this.smartReviewOriginalGoal == null) return;
const max = this.getSmartReviewMaxContinues();
if (this.smartReviewContinues >= max) {
this.setStatus(
`✅ Smart review limit reached (${this.smartReviewContinues}/${max})`,
"ok",
);
this.smartReviewOriginalGoal = null;
return;
}
let result;
try {
result = await this.client.getLastAssistantText();
} catch (err) {
console.warn("[pimate] smart review: failed to fetch last assistant text", err);
return;
}
if (!result.success) return;
const text = ((result.data as any)?.text as string | null | undefined) ?? "";
if (!text.trim()) return;
if (!this.shouldAutoContinueFromAssistantText(text)) {
this.smartReviewOriginalGoal = null;
return;
}
this.smartReviewContinues += 1;
const continuePrompt = this.buildSmartReviewContinuePrompt();
this.setStatus(
`🔁 Smart review continue ${this.smartReviewContinues}/${max}`,
"thinking",
);
this.addSystemMessage(`🔁 Smart review auto-continue (${this.smartReviewContinues}/${max})`);
try {
if (this.isStreaming) {
await this.client.steer(continuePrompt);
} else {
await this.client.prompt(continuePrompt);
}
} catch (err) {
this.addSystemMessage(
`❌ Smart review continue failed: ${(err as Error).message}`,
);
this.smartReviewOriginalGoal = null;
}
}
private buildSmartReviewContinuePrompt(): string {
const isZh = this.plugin.settings.language !== "en";
const goal = (this.smartReviewOriginalGoal || "").trim();
const goalSnippet = goal
? `${isZh ? "原始目标" : "Original goal"}: ${goal.slice(0, 400)}\n\n`
: "";
return isZh
? [
"智能审核自动继续指令:",
goalSnippet,
"你最近一轮回复看起来尚未真正完成原始目标,或者显示出还需要继续/修复/验证的信号。",
"请立即继续执行:不要再次复述目标,不要做新一轮总结性输出。",
"优先:完成未完成的步骤、运行已有测试或工具验证、修正上一轮提到的问题。",
"如果目标确实已经全部完成,请明确回复“已完成”并停止。",
].join("\n")
: [
"Smart review auto-continue:",
goalSnippet,
"Your previous reply suggests the original goal is not fully complete or contains signals that more work / verification is required.",
"Continue executing now. Do not restate the goal; do not produce another summary first.",
"Prioritize: finish remaining steps, run existing tests or tools to verify, fix issues called out last turn.",
"If the goal is genuinely fully complete, reply exactly with \"Done\" and stop.",
].join("\n");
}
private maybeTitleActiveTab(seed: string): void {
const tab = this.activeTab;
if (!tab || !/^\d+$/.test(tab.label)) return;
const title = seed
.replace(/@\S+/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 16);
if (title) {
tab.label = title;
this.renderTabs();
void this.persistSessionTabs();
}
}
private abortAgent(): void {
this.client?.abort();
this.isStreaming = false;
if (this.activeTab) this.activeTab.isStreaming = false;
this.stopSpeedIndicator();
this.updateButtons();
this.smartReviewContinues = 0;
this.smartReviewOriginalGoal = null;
this.setStatus("⏹ Aborted", "warning");
}
private async newSession(): Promise<void> {
const tab = this.activeTab;
if (!tab) return;
// 清空当前 Tab 绑定的会话参数
tab.sessionFile = undefined;
tab.sessionId = undefined;
tab.restored = false;
tab.client = null;
tab.isStreaming = false;
// 清除聊天框 DOM 状态
if (this.chatContainer) this.chatContainer.empty();
this.renderedMessages = [];
this.renderEmptyState();
this.updateWidget("tasks", undefined);
// 重新实例化并同步空白客户端
await this.ensureTabClient(tab);
this.client = tab.client;
await this.refreshStateDisplay();
await this.loadAvailableCommands();
this.updateButtons();
await this.persistSessionTabs();
const isZh = this.plugin.settings.language === "zh";
new Notice(isZh ? "已重置并开启新会话" : "Session reset and new chat started");
}
private async compactSession(): Promise<void> {
if (!this.client) return;
const isZh = this.plugin.settings.language === "zh";
// 保险60 秒后还在 thinking 就强制重置(防止事件丢失导致水印死转)
const safetyTimer = window.setTimeout(() => {
this.setStatus("⚠️ Compaction stuck (no event)", "warning");
}, 60_000);
try {
const result = await this.client.compact();
// 响应回来后强制重置状态,不管 compaction_end 事件是否被正确处理
window.clearTimeout(safetyTimer);
this.setStatus("✅ Ready", "ok");
if (result.success) {
this.compactedContextActive = true;
const summary = (result.data as any)?.summary || "";
this.addCompactionSummaryMessage(summary, (result.data as any)?.tokensBefore);
new Notice(isZh ? "上下文已压缩;可见对话已保留" : "Context compacted; visible chat preserved");
} else {
new Notice(isZh ? "上下文压缩失败" : "Compaction failed");
}
} catch (err) {
window.clearTimeout(safetyTimer);
this.setStatus("✅ Ready", "ok");
new Notice(`Compaction failed: ${(err as Error).message}`);
}
}
private showUsageStats(): void {
new UsageStatsModal(this.app, this.plugin.settings.language).open();
}
private async showStats(): Promise<void> {
if (!this.client) return;
try {
const result = await this.client.getSessionStats();
if (result.success && result.data) {
const data = result.data as any;
const tokens = data.tokens || {};
const info = [
`Messages: ${data.totalMessages || 0}`,
`Tokens: ${tokens.total || 0} (in: ${tokens.input || 0}, out: ${tokens.output || 0})`,
`Cost: $${(data.cost || 0).toFixed(4)}`,
];
if (data.contextUsage?.percent != null) {
info.push(
`Context: ${data.contextUsage.percent}% (${data.contextUsage.tokens}/${data.contextUsage.contextWindow})`
);
}
new Notice(info.join("\n"), 8000);
}
} catch (err) {
new Notice(`Failed: ${(err as Error).message}`);
}
}
private async exportSessionHtml(): Promise<void> {
if (!this.client) return;
try {
const result = await this.client.exportHtml();
if (!result.success) {
new Notice(result.error || "Export failed");
return;
}
new Notice(`Exported: ${((result.data as any)?.path || "HTML file")}`);
} catch (err) {
new Notice(`Export failed: ${(err as Error).message}`);
}
}
private async showResumeSelector(): Promise<void> {
const directory = this.getSessionDirectory();
if (!directory) {
new Notice("No session directory known yet. Send one message first.");
return;
}
try {
const sessions = this.listResumeSessions(directory);
if (sessions.length === 0) {
new Notice("No previous sessions found");
return;
}
new ResumeSessionSuggestModal(this.app, sessions, async (session) => {
new ResumeActionModal(this.app, session, async (action) => {
if (action === "open") await this.openResumeSession(session);
if (action === "delete") await this.deleteResumeSession(session);
}).open();
}).open();
} catch (err) {
new Notice(`Resume failed: ${(err as Error).message}`);
}
}
private async openResumeSession(session: ResumeSessionItem): Promise<void> {
const active = this.activeTab;
if (!active) return;
const isZh = this.plugin.settings.language === "zh";
const existing = this.tabs.find((tab) => tab.sessionFile?.toLowerCase() === session.path?.toLowerCase());
if (existing) {
await this.switchToTab(existing.id);
return;
}
// If the session belongs to a different workspace (different CWD), we
// cannot just hot-switch — the running Pi child process is pinned to the
// current vault. Force a destroy + recreate so ensureTabClient picks up
// the new session file on the next start.
const crossWorkspace = !this.isSessionFileInCurrentWorkspace(session.path);
if (crossWorkspace && active.client) {
await active.client.destroy();
active.client = null;
}
// 核心优化:若进程已运行,直接热切换 session 文件,避免拉起子进程的庞大开销,实现秒开
if (active.client && active.client.isRunning()) {
try {
this.setStatus(isZh ? "正在载入历史会话..." : "Restoring session...", "thinking");
const result = await active.client.switchSession(session.path);
if (!result.success || (result.data as any)?.cancelled) {
new Notice(isZh ? "切换历史会话失败" : "Failed to switch session");
return;
}
active.sessionFile = session.path;
active.sessionId = undefined;
active.restored = true;
await this.applyTabRuntimePreferences(active);
this.resetActiveRenderState();
if (this.chatContainer) this.chatContainer.empty();
this.renderedMessages = [];
await this.loadMessages();
await this.refreshStateDisplay();
this.setStatus("Ready", "ok");
this.updateButtons();
await this.persistSessionTabs();
return;
} catch (err) {
new Notice(isZh ? `切换历史会话出错: ${(err as Error).message}` : `Switch error: ${(err as Error).message}`);
// 异常则降级到传统的销毁重建流程
}
}
if (active.client) {
await active.client.destroy();
active.client = null;
}
active.sessionFile = session.path;
active.sessionId = undefined;
active.restored = true;
this.resetActiveRenderState();
if (this.chatContainer) this.chatContainer.empty();
this.renderedMessages = [];
await this.switchToTab(active.id);
}
private async toggleModelPopup(anchorEl: HTMLElement): Promise<void> {
if (this.modelPopupEl) {
this.closeModelPopup();
return;
}
this.closeEffortPopup();
if (!this.client) return;
const isZh = this.plugin.settings.language === "zh";
// 1. 如果有缓存,立即瞬间弹出渲染,实现“零延迟秒开”!
if (this.availableModelsCache && this.availableModelsCache.length > 0) {
this.renderModelPopup(anchorEl, this.availableModelsCache);
// 同时在后台静默抓取最新模型列表并更新缓存
this.client.getAvailableModels().then(result => {
if (result.success && result.data) {
const models = ((result.data as any).models || []) as PiModel[];
if (models.length > 0) {
this.availableModelsCache = models;
}
}
}).catch(err => {
console.warn("[pi-agent] Background model update failed:", err);
});
return;
}
// 2. 如果无缓存,先画一个 Loading 占位层,决不让界面卡死无响应
const parent = anchorEl.parentElement;
if (parent) {
this.modelPopupEl = parent.createDiv({ cls: "pi-agent-model-popup" });
const loadingEl = this.modelPopupEl.createDiv("pi-agent-model-popup-group-title");
loadingEl.setText(isZh ? "正在加载模型列表..." : "Loading models...");
// 注册关闭事件,使得即使在 Loading 期间,用户点击别处也能随时关闭它!
this.modelOutsideClickHandler = (e: MouseEvent) => {
if (this.modelPopupEl && !this.modelPopupEl.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) {
this.closeModelPopup();
}
};
window.setTimeout(() => {
activeDocument.addEventListener("pointerdown", this.modelOutsideClickHandler!);
}, 0);
}
try {
const result = await this.client.getAvailableModels();
if (!result.success || !result.data) {
if (!this.availableModelsCache) this.closeModelPopup();
return;
}
const models = ((result.data as any).models || []) as PiModel[];
if (models.length === 0) {
new Notice(isZh ? "没有可用的模型" : "No models available");
this.closeModelPopup();
return;
}
this.availableModelsCache = models;
// 关闭 Loading 骨架,渲染正式菜单
this.closeModelPopup();
this.renderModelPopup(anchorEl, models);
} catch (err) {
this.closeModelPopup();
new Notice(isZh ? `获取模型失败: ${(err as Error).message}` : `Failed to load models: ${(err as Error).message}`);
}
}
private closeModelPopup(): void {
if (this.modelPopupEl) {
this.modelPopupEl.remove();
this.modelPopupEl = null;
}
if (this.modelOutsideClickHandler) {
activeDocument.removeEventListener("pointerdown", this.modelOutsideClickHandler);
this.modelOutsideClickHandler = null;
}
}
private renderModelPopup(anchorEl: HTMLElement, models: PiModel[]): void {
const parent = anchorEl.parentElement;
if (!parent) return;
const isZh = this.plugin.settings.language === "zh";
this.modelPopupEl = parent.createDiv({ cls: "pi-agent-model-popup" });
const groups = new Map<string, PiModel[]>();
for (const model of models) {
let groupName = model.provider.toUpperCase();
if (groupName === "ANTHROPIC" || groupName === "CLAUDE") {
groupName = "CLAUDE";
}
if (!groups.has(groupName)) {
groups.set(groupName, []);
}
groups.get(groupName)!.push(model);
}
for (const [groupName, groupModels] of groups.entries()) {
const titleEl = this.modelPopupEl.createDiv("pi-agent-model-popup-group-title");
titleEl.setText(groupName);
const currentProvider = this.activeTab?.modelProvider || this.plugin.settings.provider || "";
const currentModelId = this.activeTab?.modelId || this.plugin.settings.modelId || "";
for (const model of groupModels) {
const isCurrent = currentModelId === model.id && (!currentProvider || currentProvider === model.provider);
const itemEl = this.modelPopupEl.createDiv({
cls: `pi-agent-model-popup-item ${isCurrent ? "is-active" : ""}`
});
const iconEl = itemEl.createDiv("pi-agent-model-popup-item-icon");
setIcon(iconEl, this.getProviderIconName(model.provider, model.id));
const shortName = model.name || this.getModelShortName(model.id);
itemEl.createSpan({ text: shortName, cls: "pi-agent-model-popup-item-name" });
itemEl.onclick = (e) => {
e.stopPropagation();
this.runAsync(async () => {
await this.updateActiveTabModel(model.provider, model.id);
new Notice(isZh ? `模型已切换为 ${shortName}` : `Model set to ${shortName}`);
this.closeModelPopup();
});
};
}
}
this.modelOutsideClickHandler = (e: MouseEvent) => {
if (this.modelPopupEl && !this.modelPopupEl.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) {
this.closeModelPopup();
}
};
window.setTimeout(() => {
activeDocument.addEventListener("pointerdown", this.modelOutsideClickHandler!);
}, 0);
}
private async toggleEffortPopup(anchorEl: HTMLElement): Promise<void> {
if (this.effortPopupEl) {
this.closeEffortPopup();
return;
}
this.closeModelPopup();
// Pull latest Pi state so the popup renders against current model
// metadata (reasoning / thinkingLevelMap) instead of stale local data.
const tab = this.activeTab;
if (tab?.client) {
await this.syncTabStateFromPi(tab);
}
this.renderEffortPopup(anchorEl);
}
private closeEffortPopup(): void {
if (this.effortPopupEl) {
this.effortPopupEl.remove();
this.effortPopupEl = null;
}
if (this.effortOutsideClickHandler) {
activeDocument.removeEventListener("pointerdown", this.effortOutsideClickHandler);
this.effortOutsideClickHandler = null;
}
}
// ─── Thinking level options derivation ────────────────────────────────────
//
// Pi advertises per-model thinking-level availability via
// `model.thinkingLevelMap`. We render the popup from that map's keys:
// - `reasoning !== true` → no clickable options, informational row.
// - `thinkingLevelMap` → only the keys Pi declared.
// - reasoning without map → safe fallback: show current Pi level as a
// read-only note (we do NOT invent a static list, because we have
// no authoritative source for what the model supports).
private getStaticLevelDescription(
id: string,
isZh: boolean
): { name: string; desc: string } {
const known: Record<string, { name: string; zh: string; en: string }> = {
off: { name: "off", zh: "关闭", en: "Reasoning Off" },
minimal: { name: "minimal", zh: "最低", en: "Minimal Reasoning" },
low: { name: "low", zh: "较低", en: "Low Reasoning" },
medium: { name: "medium", zh: "中等", en: "Medium Reasoning" },
high: { name: "high", zh: "较高", en: "High Reasoning" },
xhigh: { name: "xhigh", zh: "极高", en: "X-High Reasoning" },
max: { name: "max", zh: "极限", en: "Max Reasoning" },
};
const entry = known[id];
if (entry) return { name: entry.name, desc: isZh ? entry.zh : entry.en };
return {
name: id,
desc: isZh ? "当前 Pi 模型声明的档位" : "Declared by current Pi model",
};
}
private buildThinkingLevelOptions(tab: ChatTab | null | undefined): {
options: { id: string; name: string; desc: string }[];
note?: string;
} {
const isZh = this.plugin.settings.language === "zh";
const meta = tab?.piModelMeta;
if (!meta) {
return {
options: [],
note: isZh
? "等待 Pi 返回当前模型能力"
: "Awaiting Pi model capabilities",
};
}
if (meta.reasoning !== true) {
return {
options: [],
note: isZh
? "当前模型不支持可配置推理"
: "Current model does not expose configurable reasoning",
};
}
// Pi's thinkingLevelMap is an override/extension map, not a complete
// availability list. Reasoning models support the base levels; map keys
// add higher/provider-specific levels (e.g. xhigh/max) or remove a level
// when explicitly mapped to null.
const orderedKnownLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
const ids = new Set<string>(["off", "minimal", "low", "medium", "high"]);
const map = meta.thinkingLevelMap;
if (map && typeof map === "object" && !Array.isArray(map)) {
for (const [id, mapped] of Object.entries(map as Record<string, unknown>)) {
if (mapped === null) {
ids.delete(id);
} else {
ids.add(id);
}
}
}
const ordered = [
...orderedKnownLevels.filter((id) => ids.has(id)),
...Array.from(ids).filter((id) => !orderedKnownLevels.includes(id)),
];
return {
options: ordered.map((id) => ({
id,
...this.getStaticLevelDescription(id, isZh),
})),
};
}
private renderEffortPopup(anchorEl: HTMLElement): void {
const parent = anchorEl.parentElement;
if (!parent) return;
const isZh = this.plugin.settings.language === "zh";
const tab = this.activeTab;
const { options, note } = this.buildThinkingLevelOptions(tab);
this.effortPopupEl = parent.createDiv({ cls: "pi-agent-effort-popup" });
const currentLevel = tab?.thinkingLevel ?? "";
const renderItem = (
id: string,
name: string,
desc: string,
onClick?: () => void | Promise<void>
) => {
const isCurrent = currentLevel === id;
const itemEl = this.effortPopupEl!.createDiv({
cls: `pi-agent-effort-popup-item ${isCurrent ? "is-active" : ""}`,
});
const leftEl = itemEl.createSpan({ cls: "pi-agent-effort-popup-left" });
const checkEl = leftEl.createSpan({ cls: "pi-agent-effort-popup-item-check" });
checkEl.setText("✓");
leftEl.createSpan({ text: name, cls: "pi-agent-effort-popup-item-name" });
itemEl.createSpan({ text: desc, cls: "pi-agent-effort-popup-item-desc" });
if (onClick) {
itemEl.onclick = (e) => {
e.stopPropagation();
this.runAsync(async () => {
await onClick();
new Notice(isZh ? `思考强度已设为 ${name}` : `Thinking level set to ${name}`);
this.closeEffortPopup();
});
};
} else {
itemEl.addClass("is-disabled");
}
};
if (options.length === 0) {
// Informational / safe-fallback row. Do not expose a clickable level
// when Pi has not declared any supported option for this model.
const noteEl = this.effortPopupEl.createDiv({
cls: "pi-agent-effort-popup-note",
});
noteEl.setText(note ?? "");
} else {
for (const option of options) {
renderItem(option.id, option.name, option.desc, async () => {
await this.updateActiveTabThinkingLevel(option.id);
});
}
}
this.effortOutsideClickHandler = (e: MouseEvent) => {
if (this.effortPopupEl && !this.effortPopupEl.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) {
this.closeEffortPopup();
}
};
window.setTimeout(() => {
activeDocument.addEventListener("pointerdown", this.effortOutsideClickHandler!);
}, 0);
}
private async toggleHistoryPanel(): Promise<void> {
this.isHistoryOpen = !this.isHistoryOpen;
const historyBtn = this.containerEl.querySelector(".pi-agent-mini-action:has(svg.svg-icon[class*='history'])") ||
this.containerEl.querySelector(".pi-agent-mini-action svg[class*='history']")?.parentElement;
if (historyBtn) {
historyBtn.toggleClass("is-active", this.isHistoryOpen);
}
if (this.isHistoryOpen) {
if (this.chatContainer) this.chatContainer.addClass("pi-agent-hidden");
if (this.historyPanelEl) {
this.historyPanelEl.removeClass("pi-agent-hidden");
await this.renderHistoryPanel();
}
} else {
if (this.chatContainer) this.chatContainer.removeClass("pi-agent-hidden");
if (this.historyPanelEl) this.historyPanelEl.addClass("pi-agent-hidden");
}
}
private getShortPath(pathText: string): string {
if (!pathText) return "";
const sep = pathText.includes("/") ? "/" : "\\";
const parts = pathText.split(sep).filter(Boolean);
if (parts.length <= 2) return pathText;
return ".../" + parts.slice(-2).join(sep);
}
private decodeWorkspaceDirName(name: string): string {
if (name.startsWith("--") && name.endsWith("--")) {
const core = name.slice(2, -2);
if (core.includes("--")) {
const idx = core.indexOf("--");
const drive = core.slice(0, idx);
const rest = core.slice(idx + 2).replace(/-/g, "/");
return `${drive}:/${rest}`;
}
return core.replace(/-/g, "/");
}
return name;
}
private listAllWorkspaceSessions(): Array<{
pathName: string;
rawDirName: string;
dirPath: string;
isCurrent: boolean;
sessions: ResumeSessionItem[];
}> {
const home = homedir().replace(/\\/g, "/");
const sessionsBaseDir = `${home}/.pi/agent/sessions`;
if (!existsSync(sessionsBaseDir)) return [];
const vaultPath = (this.app.vault.adapter as any).getBasePath?.() || "";
const currentEncoded = vaultPath ? this.encodeWorkspacePath(vaultPath).toLowerCase() : "";
const groups = [];
const dirs = readdirSync(sessionsBaseDir);
for (const name of dirs) {
const dirPath = `${sessionsBaseDir}/${name}`;
try {
const stat = statSync(dirPath);
if (!stat.isDirectory()) continue;
const sessions = this.listResumeSessions(dirPath);
const isCurrent = name.toLowerCase() === currentEncoded;
// 解码得到可读路径
let readablePath = name;
if (name.startsWith("--") && name.endsWith("--")) {
let core = name.slice(2, -2);
if (core.includes("--")) {
const idx = core.indexOf("--");
const drive = core.slice(0, idx);
const rest = core.slice(idx + 2).replace(/-/g, "/");
readablePath = `${drive}:/${rest}`;
} else {
readablePath = core.replace(/-/g, "/");
}
}
groups.push({
pathName: readablePath,
rawDirName: name,
dirPath,
isCurrent,
sessions,
});
} catch (e) {
new Notice(`读取目录 ${name} 报错: ${(e as Error).message}`);
console.error("[pi-agent] listAllWorkspaceSessions error", e);
}
}
// 排序:当前工作区最前,其余按路径名排序
return groups.sort((a, b) => {
if (a.isCurrent) return -1;
if (b.isCurrent) return 1;
return a.pathName.localeCompare(b.pathName);
});
}
private async renderHistoryPanel(): Promise<void> {
if (!this.historyPanelEl) return;
this.historyPanelEl.empty();
const isZh = this.plugin.settings.language === "zh";
const header = this.historyPanelEl.createDiv("pi-agent-history-header");
header.createDiv({ text: "CONVERSATIONS", cls: "pi-agent-history-title" });
try {
// Keep the custom History UI, but use exactly the same data source as
// Resume session...: current resume directory + listResumeSessions().
const directory = this.getSessionDirectory();
if (!directory) {
this.historyPanelEl.createDiv({
text: isZh ? "暂无会话历史" : "No conversation history",
cls: "pi-agent-history-empty",
});
return;
}
const sessions = this.listResumeSessions(directory);
if (sessions.length === 0) {
this.historyPanelEl.createDiv({
text: isZh ? "暂无会话历史" : "No conversation history",
cls: "pi-agent-history-empty",
});
return;
}
const searchWrap = this.historyPanelEl.createDiv("pi-agent-history-search-wrap");
const searchInput = searchWrap.createEl("input", {
cls: "pi-agent-history-search",
attr: {
type: "search",
placeholder: isZh ? "搜索历史会话..." : "Search conversations...",
},
});
const listContainer = this.historyPanelEl.createDiv("pi-agent-history-list");
const renderList = (query = "") => {
listContainer.empty();
const q = query.trim().toLowerCase();
const filtered = q
? sessions.filter((session) => {
const haystack = [session.label, session.preview, session.path]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(q);
})
: sessions;
if (filtered.length === 0) {
listContainer.createDiv({
text: isZh ? "没有匹配的会话" : "No matching conversations",
cls: "pi-agent-history-empty",
});
return;
}
const currentSessionPath = this.activeTab?.sessionFile
?.replace(/\\/g, "/")
.toLowerCase();
for (const session of filtered) {
const sessionPath = session.path?.replace(/\\/g, "/").toLowerCase();
const isCurrentSession = !!currentSessionPath && sessionPath === currentSessionPath;
const itemEl = listContainer.createDiv(
isCurrentSession
? "pi-agent-history-item is-current-session"
: "pi-agent-history-item"
);
const iconEl = itemEl.createDiv("pi-agent-history-item-icon");
setIcon(iconEl, isCurrentSession ? "message-square-dot" : "message-square");
const contentEl = itemEl.createDiv("pi-agent-history-item-content");
const nameText = session.label || (isZh ? "未命名对话" : "Untitled Session");
contentEl.createDiv({ text: nameText, cls: "pi-agent-history-item-name" });
if (isCurrentSession) {
contentEl.createDiv({
text: "Current session",
cls: "pi-agent-history-item-current",
});
} else {
const timeText = this.formatHistoryTime(session.mtime);
contentEl.createDiv({ text: timeText, cls: "pi-agent-history-item-time" });
}
itemEl.onclick = () => {
this.runAsync(async () => {
await this.openResumeSession(session);
this.isHistoryOpen = false;
if (this.chatContainer) this.chatContainer.removeClass("pi-agent-hidden");
this.historyPanelEl!.addClass("pi-agent-hidden");
const historyBtn = this.containerEl.querySelector(".pi-agent-mini-action.is-active");
historyBtn?.removeClass("is-active");
});
};
itemEl.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
const menu = new Menu();
menu.addItem((item: any) => {
item
.setTitle(isZh ? "重命名" : "Rename")
.setIcon("pencil")
.onClick(() => this.runAsync(async () => {
await this.renameResumeSession(session);
await this.renderHistoryPanel();
}));
});
menu.addItem((item: any) => {
item
.setTitle(isZh ? "删除此会话" : "Delete session")
.setIcon("trash-2")
.onClick(() => this.runAsync(async () => {
await this.deleteResumeSession(session);
await this.renderHistoryPanel();
}));
});
menu.showAtMouseEvent(e);
});
}
};
searchInput.addEventListener("input", () => renderList(searchInput.value));
renderList();
} catch (err) {
this.historyPanelEl.createDiv({
text: `Failed to load: ${(err as Error).message}`,
cls: "pi-agent-history-error",
});
}
}
private formatHistoryTime(mtime: number): string {
const isZh = this.plugin.settings.language === "zh";
const date = new Date(mtime);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
}
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (date.toDateString() === yesterday.toDateString()) {
return isZh ? "昨天" : "Yesterday";
}
const month = date.getMonth() + 1;
const day = date.getDate();
return isZh ? `${month}${day}` : date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
private async renameResumeSession(session: ResumeSessionItem): Promise<void> {
const isZh = this.plugin.settings.language === "zh";
const current = this.plugin.settings.sessionTitles?.[session.path] || session.label || "";
const value = await new Promise<string | null>((resolve) => {
new PiAgentEditorModal(
this.app,
isZh ? "重命名会话" : "Rename session",
current,
resolve
).open();
});
if (value === null) return;
const title = value.trim();
if (!this.plugin.settings.sessionTitles) this.plugin.settings.sessionTitles = {};
if (title) {
this.plugin.settings.sessionTitles[session.path] = title;
session.label = title;
} else {
delete this.plugin.settings.sessionTitles[session.path];
}
await this.plugin.saveSettings();
new Notice(isZh ? "会话已重命名" : "Session renamed");
}
private async deleteResumeSession(session: ResumeSessionItem): Promise<void> {
const confirmed = await new Promise<boolean>((resolve) => {
new PiAgentConfirmModal(
this.app,
"Delete Pimate session?",
`Delete this session file?\n\n${session.path}\n\nThis cannot be undone from Pimate.`,
resolve
).open();
});
if (!confirmed) return;
const tab = this.tabs.find((item) => item.sessionFile?.toLowerCase() === session.path?.toLowerCase());
if (tab) await this.closeTab(tab.id);
try {
unlinkSync(session.path);
if (this.plugin.settings.sessionTitles?.[session.path]) {
delete this.plugin.settings.sessionTitles[session.path];
await this.plugin.saveSettings();
}
new Notice("Session deleted");
} catch (err) {
new Notice(`Failed to delete session: ${(err as Error).message}`);
}
}
private encodeWorkspacePath(vaultPath: string): string {
let p = vaultPath.replace(/\\/g, "/");
if (p.match(/^[A-Za-z]:/)) {
const drive = p[0].toUpperCase();
let rest = p.slice(2);
if (rest.startsWith("/")) rest = rest.slice(1);
const restEncoded = rest.replace(/\//g, "-");
return `--${drive}--${restEncoded}--`;
} else {
if (p.startsWith("/")) p = p.slice(1);
if (p.endsWith("/")) p = p.slice(0, -1);
return `--${p.replace(/\//g, "-")}--`;
}
}
private getSessionDirectory(): string {
try {
const vaultPath = (this.app.vault.adapter as any).getBasePath?.() || "";
if (vaultPath) {
const encodedDirName = this.encodeWorkspacePath(vaultPath);
const home = homedir().replace(/\\/g, "/");
const sessionsBaseDir = `${home}/.pi/agent/sessions`;
// 1. 优先使用原本的 directory
const directory = `${sessionsBaseDir}/${encodedDirName}`;
if (existsSync(directory)) {
return directory;
}
// 2. 如果不存在,在 sessions 目录下进行大小写无关的查找
if (existsSync(sessionsBaseDir)) {
const targetNameLower = encodedDirName.toLowerCase();
const dirs = readdirSync(sessionsBaseDir);
const matchedDir = dirs.find((d: string) => d.toLowerCase() === targetNameLower);
if (matchedDir) {
return `${sessionsBaseDir}/${matchedDir}`;
}
}
}
} catch (err) {
console.log("[pi-agent] Failed to auto detect workspace sessions dir, fallback to old logic", err);
}
const sessionFile =
this.activeTab?.sessionFile ||
this.plugin.settings.activeSessionFile ||
this.plugin.settings.sessionTabs?.find((tab) => tab.sessionFile)?.sessionFile ||
"";
return sessionFile ? dirname(sessionFile) : "";
}
private listResumeSessions(directory: string): ResumeSessionItem[] {
return readdirSync(directory)
.filter((name) => name.endsWith(".jsonl"))
.map((name) => {
const path = `${directory}/${name}`;
const stat = statSync(path);
const preview = this.readSessionPreview(path);
const customTitle = this.plugin.settings.sessionTitles?.[path];
return {
path,
label: customTitle || (preview ? preview.slice(0, 24) : basename(name, ".jsonl").slice(0, 12)),
mtime: stat.mtimeMs,
preview,
};
})
.sort((a, b) => b.mtime - a.mtime)
.slice(0, 200);
}
private readSessionPreview(path: string): string {
try {
const text = readFileSync(path, "utf8");
for (const line of text.split(/\r?\n/)) {
if (!line.trim()) continue;
const entry = JSON.parse(line) as any;
const content = entry.message?.content ?? entry.content;
const role = entry.message?.role ?? entry.role;
if (role === "user") {
if (typeof content === "string") return content.replace(/\s+/g, " ").trim();
if (Array.isArray(content)) {
const part = content.find((item) => item?.type === "text" && item.text);
if (part?.text) return String(part.text).replace(/\s+/g, " ").trim();
}
}
}
} catch {
// Ignore malformed/locked session files.
}
return "";
}
/**
* Read the last N message entries from a jsonl session file directly.
* Returns { messages, total } where total is the count of all message entries.
*
* This bypasses the Pi RPC roundtrip which would otherwise serialize the
* whole session.messages to JSON and pipe it back. For multi-MB sessions
* (3000+ messages) this is 10-50x faster than the RPC path.
*/
private readLastMessagesFromFile(
filePath: string,
limit: number
): { messages: any[]; total: number } {
try {
if (!existsSync(filePath)) return { messages: [], total: 0 };
// Read whole file once. 30MB on SSD = ~50-200ms; comparable to the
// RPC overhead we're avoiding. We do NOT go through MarkdownRenderer
// here, so this is cheap relative to the render step.
const text = readFileSync(filePath, "utf8");
const messageLines: any[] = [];
for (const line of text.split(/\r?\n/)) {
if (!line.trim()) continue;
try {
const e = JSON.parse(line);
if (e?.type === "message" && e.message) {
messageLines.push(e.message);
}
} catch {
// skip malformed
}
}
const total = messageLines.length;
const messages = limit > 0 ? messageLines.slice(-limit) : messageLines;
return { messages, total };
} catch (err) {
console.warn("[pi-agent] readLastMessagesFromFile failed:", err);
return { messages: [], total: 0 };
}
}
private async showForkSelector(): Promise<void> {
if (!this.client) return;
try {
const result = await this.client.getForkMessages();
const messages = (((result.data as any)?.messages || []) as ForkMessage[]).filter(
(item) => item.entryId && item.text
);
if (!result.success || messages.length === 0) {
new Notice("No previous user prompts available to fork");
return;
}
new ForkMessageSuggestModal(this.app, messages, async (message) => {
const forked = await this.client?.fork(message.entryId);
if (!forked?.success || (forked.data as any)?.cancelled) {
new Notice("Fork cancelled");
return;
}
this.setInputText(((forked.data as any)?.text || message.text).trim());
this.resetActiveRenderState();
if (this.chatContainer) this.chatContainer.empty();
this.renderedMessages = [];
await this.loadMessages();
new Notice("Fork created");
}).open();
} catch (err) {
new Notice(`Fork failed: ${(err as Error).message}`);
}
}
private async cloneCurrentBranch(): Promise<void> {
if (!this.client) return;
try {
const result = await this.client.clone();
if (!result.success || (result.data as any)?.cancelled) {
new Notice("Clone cancelled");
return;
}
this.resetActiveRenderState();
if (this.chatContainer) this.chatContainer.empty();
this.renderedMessages = [];
await this.loadMessages();
new Notice("Current branch cloned");
} catch (err) {
new Notice(`Clone failed: ${(err as Error).message}`);
}
}
private async runBashMode(message: string): Promise<void> {
if (!this.client) return;
const command = message.replace(/^!+/, "").trim();
if (!command) return;
if (this.isDangerousBashCommand(command)) {
const allowed = await new Promise<boolean>((resolve) => {
new PiAgentConfirmModal(
this.app,
"Dangerous bash command",
`Pimate is about to run:\n\n${command}\n\nThis looks destructive. Allow it?`,
resolve
).open();
});
if (!allowed) {
this.addSystemMessage("Dangerous bash command blocked");
return;
}
}
const toolMsg = this.addMessage("assistant", "");
const toolBlock = toolMsg.contentEl.createDiv("pi-agent-tool-block");
const header = toolBlock.createDiv("pi-agent-tool-header");
header.createSpan({ text: this.getToolIcon("bash"), cls: "pi-agent-tool-icon" });
header.createSpan({ text: "Bash", cls: "pi-agent-tool-name" });
header.createSpan({ text: command, cls: "pi-agent-tool-args" });
header.createSpan({ text: "...", cls: "pi-agent-tool-close is-loading" });
const outputEl = toolBlock.createDiv("pi-agent-tool-output is-visible");
header.onclick = () => outputEl.toggleClass("is-visible", !outputEl.hasClass("is-visible"));
try {
const result = await this.client.bash(command);
const data = result.data as any;
const closeEl = toolBlock.querySelector(".pi-agent-tool-close") as HTMLElement | null;
if (closeEl) {
closeEl.removeClass("is-loading");
closeEl.textContent = result.success ? "✓" : "×";
}
outputEl.setText((data?.output || "").slice(0, 3000));
if (!result.success) outputEl.addClass("pi-agent-tool-error");
} catch (err) {
const closeEl = toolBlock.querySelector(".pi-agent-tool-close") as HTMLElement | null;
if (closeEl) {
closeEl.removeClass("is-loading");
closeEl.textContent = "×";
}
outputEl.setText((err as Error).message);
outputEl.addClass("pi-agent-tool-error");
}
}
private isDangerousBashCommand(command: string): boolean {
const normalized = command.toLowerCase().replace(/\s+/g, " ").trim();
return [
/\brm\s+-[^\n]*r[^\n]*f\b/,
/\brm\s+-rf\b/,
/\bdel\s+\/s\b/,
/\brmdir\s+\/s\b/,
/\bformat\b/,
/\bgit\s+reset\s+--hard\b/,
/\bgit\s+clean\s+-[^\n]*f/,
/\bmkfs\b/,
/\bshutdown\b/,
/\breboot\b/,
].some((pattern) => pattern.test(normalized));
}
private async showCommandSelector(): Promise<void> {
if (!this.client) return;
try {
const result = await this.client.getCommands();
if (!result.success || !result.data) return;
const commands = ((result.data as any).commands || []) as PiCommand[];
if (!commands.length) {
new Notice("No Pi commands or skills available");
return;
}
new CommandSuggestModal(this.app, commands, (command) => {
this.prependInputText(`/${command.name} `);
}).open();
} catch (err) {
new Notice(`Failed: ${(err as Error).message}`);
}
}
private async showModelSelector(): Promise<void> {
if (!this.client) return;
try {
const result = await this.client.getAvailableModels();
if (!result.success || !result.data) return;
const models = ((result.data as any).models || []) as PiModel[];
if (models.length === 0) {
new Notice("No models available");
return;
}
new ModelSuggestModal(this.app, models, async (model) => {
await this.updateActiveTabModel(model.provider, model.id);
new Notice(`Model set to ${model.provider}/${model.id}`);
}).open();
} catch (err) {
new Notice(`Failed: ${(err as Error).message}`);
}
}
async inlineEditSelection(
selection: string,
applyReplacement: (replacement: string) => void
): Promise<void> {
if (!this.client) return;
const trimmed = selection.trim();
if (!trimmed) return;
const instruction = await new Promise<string | null>((resolve) => {
new PiAgentInlineEditModal(this.app, resolve).open();
});
if (!instruction) return;
this.addSystemMessage("Inline edit started…");
let attempt = 1;
try {
while (true) {
const replacement = await this.generateInlineReplacement(selection, instruction, attempt);
if (!replacement) {
new Notice("Pimate returned an empty replacement");
return;
}
const review = await new Promise<InlineEditReviewResult>((resolve) => {
new PiAgentInlineEditReviewModal(
this.app,
selection,
replacement,
resolve
).open();
});
if (review.action === "reject") {
new Notice("Inline edit rejected");
return;
}
if (review.action === "regenerate") {
attempt++;
this.addSystemMessage(`Regenerating inline edit… (${attempt})`);
continue;
}
applyReplacement((review.replacement || replacement).trim());
new Notice("Selection edited by Pimate");
return;
}
} catch (err) {
new Notice(`Inline edit failed: ${(err as Error).message}`);
}
}
private async generateInlineReplacement(
selection: string,
instruction: string,
attempt: number
): Promise<string> {
if (!this.client) return "";
const prompt = [
"You are editing a selected passage from an Obsidian markdown note.",
"Return ONLY the replacement text. Do not add explanations, markdown fences, or commentary.",
`Instruction: ${instruction}`,
attempt > 1 ? `This is regeneration attempt ${attempt}. Produce a different, better version.` : "",
"Selected text:",
"```markdown",
selection,
"```",
]
.filter(Boolean)
.join("\n");
const result = await this.client.promptAndWait(prompt);
return ((result.data as any)?.text || "").trim();
}
/**
* Files that Pimate considers attachable to chat context.
* Includes markdown (for reading), PDFs (for vision-capable models),
* and common image formats (for vision models).
*/
private getAttachableFiles(): TFile[] {
const exts = new Set([
"md", "markdown",
"pdf",
"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif",
]);
return this.app.vault
.getFiles()
.filter((f) => exts.has(f.extension.toLowerCase()));
}
/** Returns a small emoji-style tag for the file type (used in @ dropdown). */
private getFileTypeIcon(extension: string): string {
const e = extension.toLowerCase();
if (e === "pdf") return "📄";
if (["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"].includes(e)) return "🖼";
return "📝"; // markdown
}
private async addFileContext(): Promise<void> {
const files = this.getAttachableFiles();
if (files.length === 0) {
new Notice("No attachable files in this vault");
return;
}
new FileSuggestModal(this.app, files, (file) => this.addFileContextItem(file)).open();
}
private addCurrentFileContext(): void {
const file = this.app.workspace.getActiveFile();
if (!file) {
new Notice("No active file");
return;
}
this.addFileContextItem(file);
}
public addFileContextItem(file: TFile): void {
this.addContextItem({
id: `ctx-${Date.now()}-${Math.random().toString(36).slice(2)}`,
type: "file",
label: file.basename,
value: file.path,
});
}
public addFolderContextItem(folder: TFolder, isRecursive: boolean): void {
const path = folder.path || "/";
this.addContextItem({
id: `ctx-${Date.now()}-${Math.random().toString(36).slice(2)}`,
type: "folder",
label: folder.name || path,
value: path,
mimeType: isRecursive ? "recursive" : "files",
});
}
private async handlePaste(event: ClipboardEvent): Promise<void> {
const items = Array.from(event.clipboardData?.items ?? []);
const imageItems = items.filter((item) => item.type.startsWith("image/"));
if (imageItems.length === 0) return;
event.preventDefault();
for (const item of imageItems) {
const file = item.getAsFile();
if (!file) continue;
await this.addImageContextFromFile(file, "pasted image");
}
}
private async handleDrop(event: DragEvent): Promise<void> {
event.preventDefault();
this.inputEl?.removeClass("is-drag-over");
const files = Array.from(event.dataTransfer?.files ?? []);
let handled = false;
for (const file of files) {
if (file.type.startsWith("image/")) {
await this.addImageContextFromFile(file, file.name || "dropped image");
handled = true;
}
}
const text = event.dataTransfer?.getData("text/plain") || "";
for (const path of this.extractVaultPaths(text)) {
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
this.addFileContextItem(file);
handled = true;
}
}
if (handled) this.inputEl?.focus();
}
private async addImageContextFromFile(file: File, fallbackLabel: string): Promise<void> {
const dataUrl = await this.readFileAsDataUrl(file);
const [, base64 = ""] = dataUrl.split(",", 2);
this.addContextItem({
id: `ctx-img-${Date.now()}-${Math.random().toString(36).slice(2)}`,
type: "image",
label: file.name && file.name !== "image.png" ? file.name : fallbackLabel,
value: base64,
mimeType: file.type || "image/png",
});
}
private extractVaultPaths(text: string): string[] {
if (!text) return [];
const candidates = text
.split(/\r?\n/)
.map((line) => line.trim().replace(/^obsidian:\/\/open\?/, ""))
.filter(Boolean);
const paths: string[] = [];
for (const candidate of candidates) {
const decoded = decodeURIComponent(candidate);
const fileMatch = decoded.match(/(?:^|[?&])file=([^&]+)/);
const path = fileMatch ? decodeURIComponent(fileMatch[1]) : decoded;
if (this.app.vault.getAbstractFileByPath(path) instanceof TFile) paths.push(path);
}
return paths;
}
private readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("Failed to read pasted image"));
reader.readAsDataURL(file);
});
}
private addContextItem(item: ContextItem): void {
if (this.contextItems.some((existing) => existing.value === item.value)) return;
this.contextItems.push(item);
this.renderContextItems();
}
private removeContextItem(id: string): void {
this.contextItems = this.contextItems.filter((item) => item.id !== id);
this.renderContextItems();
}
private clearContextItems(): void {
this.contextItems = [];
this.renderContextItems();
}
private renderContextItems(): void {
if (!this.contextRowEl) return;
this.contextRowEl.empty();
if (this.imagePreviewEl) this.imagePreviewEl.empty();
// Split: images go to a dedicated large-thumbnail preview row;
// files/folders/selections stay in the chip row.
const images = this.contextItems.filter((i) => i.type === "image");
const others = this.contextItems.filter((i) => i.type !== "image");
if (this.imagePreviewEl) {
this.imagePreviewEl.toggleClass("has-content", images.length > 0);
for (const item of images) {
const card = this.imagePreviewEl.createDiv("pi-agent-image-card");
const img = card.createEl("img", {
cls: "pi-agent-image-card-thumb",
attr: {
src: `data:${item.mimeType || "image/png"};base64,${item.value}`,
title: item.label,
},
});
img.onclick = (e) => {
e.stopPropagation();
new ContextPreviewModal(this.app, item).open();
};
const remove = card.createSpan({ text: "×", cls: "pi-agent-image-card-remove" });
remove.onclick = (e) => {
e.stopPropagation();
this.removeContextItem(item.id);
};
}
}
this.contextRowEl.toggleClass("has-content", others.length > 0);
for (const item of others) {
const chip = this.contextRowEl.createSpan({ cls: "pi-agent-file-chip" });
chip.createSpan({
text: item.type === "selection" ? "▤" : item.type === "folder" ? "▦" : "▣",
cls: "pi-agent-file-chip-icon",
});
chip.createSpan({ text: item.label, cls: "pi-agent-file-chip-name" });
const remove = chip.createSpan({ text: "×", cls: "pi-agent-file-chip-remove" });
remove.onclick = (event) => {
event.stopPropagation();
this.removeContextItem(item.id);
};
chip.onclick = () => {
this.runAsync(async () => {
if (item.type === "file") {
const file = this.app.vault.getAbstractFileByPath(item.value);
if (file instanceof TFile) await this.app.workspace.getLeaf(false).openFile(file);
} else if (item.type === "folder") {
// Open the first markdown file in the folder.
const all = this.app.vault.getMarkdownFiles();
const prefix = item.value === "/" ? "" : item.value + "/";
const first = all.find((f) => {
const parent = f.parent;
if (!parent) return false;
return item.value === "/"
? !f.path.includes("/")
: f.path.startsWith(prefix) || parent.path === item.value;
});
if (first) {
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(first);
} else {
const fileExplorer = this.app.workspace.getLeavesOfType("file-explorer")[0];
if (fileExplorer) {
this.app.workspace.setActiveLeaf(fileExplorer);
}
}
} else {
new ContextPreviewModal(this.app, item).open();
}
});
};
}
}
/**
* Render image attachments at the top of a user message bubble.
* Mirrors Claudian's layout: image(s) above the text prompt, click to zoom.
*/
private renderUserMessageImages(
msg: RenderedMessage,
images: Array<{ data: string; mimeType: string }>
): void {
const attachments = msg.el.createDiv("pi-agent-message-attachments");
// Move the attachments block above the text content.
msg.el.insertBefore(attachments, msg.contentEl);
for (const img of images) {
const card = attachments.createDiv("pi-agent-message-image");
const el = card.createEl("img", {
attr: { src: `data:${img.mimeType || "image/png"};base64,${img.data}` },
});
el.onclick = (e) => {
e.stopPropagation();
const url = `data:${img.mimeType || "image/png"};base64,${img.data}`;
window.open(url, "_blank");
};
}
}
private stripRecentContextGuard(text: string): string {
return text
.replace(/<recent_context_guard>[\s\S]*?<\/recent_context_guard>\s*/g, "")
.trim();
}
private buildContextPrefix(): string {
const fileItems = this.contextItems.filter((item) => item.type === "file");
const folderItems = this.contextItems.filter((item) => item.type === "folder");
const selectionItems = this.contextItems.filter((item) => item.type === "selection");
const fileText = fileItems.map((item) => `@${item.value}`).join(" ");
const folderText = folderItems
.map((item) => `Folder @${item.value}`)
.join("\n\n");
const selectionText = selectionItems
.map((item, index) => `Selection ${index + 1}:\n${item.value}`)
.join("\n\n");
const parts = [fileText, folderText, selectionText].filter(Boolean);
return parts.length ? `${parts.join("\n\n")}\n\n` : "";
}
private listFolderFiles(folderPath: string, recursive: boolean): string[] {
const allFiles = this.app.vault.getMarkdownFiles();
const out: string[] = [];
for (const f of allFiles) {
const parent = f.parent;
if (!parent) continue;
const parentPath = parent.path || "/";
if (
parentPath === folderPath ||
(recursive &&
parentPath.startsWith(folderPath === "/" ? "/" : folderPath + "/"))
) {
out.push(f.path);
}
}
return out;
}
private getImagePayloads(): Array<{ type: string; data: string; mimeType: string }> {
return this.contextItems
.filter((item) => item.type === "image")
.map((item) => ({
type: "image",
data: item.value,
mimeType: item.mimeType || "image/png",
}));
}
private async refreshStateDisplay(): Promise<void> {
if (!this.client) return;
try {
if (this.activeTab) {
await this.syncTabStateFromPi(this.activeTab);
}
await this.refreshContextUsageDisplay();
// 预热可用模型列表缓存,确保点击弹出时能“秒开”且不阻塞用户
this.client.getAvailableModels().then(res => {
if (res.success && res.data) {
this.availableModelsCache = (res.data.models || []) as PiModel[];
}
}).catch(() => {});
} catch {
// Non-fatal; UI can still function without state display.
}
}
private async refreshContextUsageDisplay(): Promise<void> {
if (!this.client || !this.footerContextEl) return;
try {
const result = await this.client.getSessionStats();
if (result.success) {
this.updateActiveTabSessionInfo(result.data as any);
await this.persistSessionTabs();
}
const usage = (result.data as any)?.contextUsage;
if (result.success && usage?.percent != null) {
this.updateContextMeter(Number(usage.percent), `Context: ${usage.tokens ?? "?"}/${usage.contextWindow ?? "?"}`);
} else {
this.updateContextMeter(null, "Context usage");
}
} catch {
this.updateContextMeter(null, "Context usage");
}
}
private updateContextMeter(percent: number | null, title: string): void {
if (!this.footerContextEl || !this.footerContextFillEl || !this.footerContextPercentEl) return;
const circumference = 2 * Math.PI * 8;
this.footerContextFillEl.setAttribute("stroke-dasharray", `${circumference}`);
if (percent == null || Number.isNaN(percent)) {
this.footerContextPercentEl.setText("");
this.footerContextFillEl.setAttribute("stroke-dashoffset", `${circumference}`);
this.footerContextEl.removeClass("warning");
this.footerContextEl.removeClass("danger");
this.footerContextEl.setAttribute("title", title);
return;
}
const clamped = Math.max(0, Math.min(100, percent));
this.footerContextFillEl.setAttribute("stroke-dashoffset", `${circumference * (1 - clamped / 100)}`);
this.footerContextPercentEl.setText(`${Math.round(clamped)}%`);
this.footerContextEl.toggleClass("warning", clamped >= 70 && clamped < 85);
this.footerContextEl.toggleClass("danger", clamped >= 85);
this.footerContextEl.setAttribute("title", title);
}
private updateActiveTabSessionInfo(data: any): void {
const tab = this.activeTab;
if (!tab || !data) return;
if (typeof data.sessionFile === "string") tab.sessionFile = data.sessionFile;
if (typeof data.sessionId === "string") tab.sessionId = data.sessionId;
const maybeName = data.name || data.sessionName || data.title;
if (typeof maybeName === "string" && maybeName.trim()) {
tab.label = maybeName.trim().slice(0, 24);
this.renderTabs();
}
}
private async persistSessionTabs(): Promise<void> {
this.plugin.settings.sessionTabs = this.tabs.map((tab) => ({
label: tab.label,
sessionFile: tab.sessionFile,
sessionId: tab.sessionId,
modelProvider: tab.modelProvider,
modelId: tab.modelId,
thinkingLevel: tab.thinkingLevel,
}));
this.plugin.settings.activeSessionFile = this.activeTab?.sessionFile || "";
await this.plugin.saveSettings();
}
private restoreTabModelConfig(): void {
const persisted = this.plugin.settings.sessionTabs || [];
for (let i = 0; i < this.tabs.length && i < persisted.length; i++) {
const saved = persisted[i];
if (!saved) continue;
if (saved.modelProvider) this.tabs[i].modelProvider = saved.modelProvider;
if (saved.modelId) this.tabs[i].modelId = saved.modelId;
if (typeof saved.thinkingLevel === "string") this.tabs[i].thinkingLevel = saved.thinkingLevel;
}
}
private updateModelDisplay(provider: string, modelId: string): void {
if (!this.footerModelLabel) return;
const shortName = modelId
.replace(/^claude-/, "")
.replace(/^gpt-/, "GPT-")
.replace(/^deepseek-/, "DeepSeek ")
.slice(0, 18);
this.footerModelLabel.setText(shortName || provider);
this.footerModelLabel.setAttribute("title", `${provider}/${modelId}`);
}
public refreshSmartReviewToggle(): void {
this.updateSmartReviewToggleUI();
}
private updateSmartReviewToggleUI(): void {
if (!this.smartReviewToggleEl) return;
const isEnabled = this.plugin.settings.smartReviewEnabled === true;
const isZh = this.plugin.settings.language !== "en";
this.smartReviewToggleEl.setText(isZh ? "审" : "Review");
this.smartReviewToggleEl.setAttribute(
"title",
isEnabled
? isZh
? "智能审核已开启:长任务会自检并优化后再输出"
: "Smart review on: long tasks self-check before replying"
: isZh
? "智能审核已关闭"
: "Smart review off"
);
this.smartReviewToggleEl.toggleClass("is-enabled", isEnabled);
}
async insertLastAssistantIntoActiveNote(): Promise<void> {
if (!this.client) return;
const activeMarkdown =
this.app.workspace.getActiveViewOfType(MarkdownView) ||
(this.app.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.find((view): view is MarkdownView => view instanceof MarkdownView) ??
null);
const editor = activeMarkdown?.editor;
if (!editor) {
new Notice("Open a markdown note first");
return;
}
try {
const result = await this.client.getLastAssistantText();
const text = (result.data as any)?.text as string | null | undefined;
if (!result.success || !text) {
new Notice("No Pimate response to insert");
return;
}
editor.replaceSelection(text);
new Notice("Inserted last Pimate response");
} catch (err) {
new Notice(`Insert failed: ${(err as Error).message}`);
}
}
public async refreshThinkingVisibility(): Promise<void> {
if (this.activeTab?.isStreaming) return;
await this.reloadMessagesFromClient();
}
private async reloadMessagesFromClient(): Promise<void> {
if (this.chatContainer) {
this.chatContainer.empty();
}
this.renderedMessages = [];
this.historyShownCount = 0;
this.historyTotalCount = 0;
this.historyBannerEl = null;
this.renderEmptyState();
await this.loadMessages();
}
private async loadMessages(): Promise<void> {
if (!this.client) return;
// Decide source: prefer direct file read (fast, paginated) when we
// have a known sessionFile. Falls back to RPC for sessions that live
// only in memory (e.g. --no-session) or when the file is missing.
const tab = this.activeTab;
const filePath = tab?.sessionFile;
const limit = this.plugin.settings.maxHistoryDisplay;
let messages: any[] = [];
let total = 0;
let usedFile = false;
// Prefer direct jsonl reads when possible. During streaming, RPC getMessages
// can contend with live generation on the same subprocess and make large
// model replies feel slower; missed live deltas are recovered by
// ensureAssistantStreamMessage().
if (filePath) {
const fileResult = this.readLastMessagesFromFile(filePath, limit);
if (fileResult.total > 0) {
messages = fileResult.messages;
total = fileResult.total;
usedFile = true;
}
}
if (!usedFile) {
try {
const result = await this.client.getMessages();
if (result.success && result.data) {
const rpcMessages = (result.data as any).messages || [];
total = rpcMessages.length;
messages = limit > 0 ? rpcMessages.slice(-limit) : rpcMessages;
}
} catch {
console.log("[pi-agent] No existing messages to load");
return;
}
}
this.historyShownCount = messages.length;
this.historyTotalCount = total;
for (const msg of messages) {
this.renderMessageFromHistory(msg);
}
this.renderHistoryBanner();
this.scrollToBottom(true, true);
}
/** Append more history (e.g. when user clicks "Load earlier"). */
private async loadMoreHistory(moreBy: number): Promise<void> {
const tab = this.activeTab;
if (!tab?.sessionFile) return;
const newLimit = this.historyShownCount + moreBy;
const fileResult = this.readLastMessagesFromFile(tab.sessionFile, newLimit);
if (fileResult.messages.length <= this.historyShownCount) {
new Notice("No more messages to load");
return;
}
const newOnes = fileResult.messages.slice(this.historyShownCount);
// Preserve scroll position roughly: remember old scrollHeight
const chat = this.chatContainer;
const oldScrollHeight = chat?.scrollHeight || 0;
for (const msg of newOnes) {
this.renderMessageFromHistory(msg);
}
this.historyShownCount = fileResult.messages.length;
this.historyTotalCount = fileResult.total;
this.renderHistoryBanner();
if (chat) {
// Keep the user's view stable after prepending.
const newScrollHeight = chat.scrollHeight;
chat.scrollTop = newScrollHeight - oldScrollHeight + (chat.scrollTop || 0);
}
}
/** Render the "Showing N of M" + "Load earlier" banner above the chat. */
private renderHistoryBanner(): void {
if (!this.chatContainer) return;
if (this.historyBannerEl) this.historyBannerEl.remove();
this.historyBannerEl = null;
if (this.historyTotalCount <= this.historyShownCount) return;
const banner = this.chatContainer.createDiv("pi-agent-history-banner");
const text = banner.createSpan("pi-agent-history-banner-text");
text.setText(
`Showing the latest ${this.historyShownCount} of ${this.historyTotalCount} messages`
);
const btn = banner.createEl("button", {
text: "Load 50 more",
cls: "pi-agent-history-banner-btn",
});
btn.onclick = () => {
btn.setText("Loading…");
btn.disabled = true;
void this.loadMoreHistory(50).finally(() => {
btn.setText("Load 50 more");
btn.disabled = false;
});
};
const allBtn = banner.createEl("button", {
text: "Load all",
cls: "pi-agent-history-banner-btn pi-agent-history-banner-btn-secondary",
});
allBtn.onclick = () => {
allBtn.setText("Loading…");
allBtn.disabled = true;
void this.loadMoreHistory(this.historyTotalCount).finally(() => {
allBtn.setText("Load all");
allBtn.disabled = false;
});
};
this.chatContainer.prepend(banner);
this.historyBannerEl = banner;
}
private finalizeAssistantMessageVisibility(message: RenderedMessage): void {
const hasText = !!message.contentEl.querySelector(".pi-agent-text-block");
const thinkingBlocks = Array.from(
message.contentEl.querySelectorAll(".pi-agent-thinking-block")
) as HTMLElement[];
const hasThinking = thinkingBlocks.some((block) => {
const content = block.querySelector(".pi-agent-thinking-content") as HTMLElement | null;
return !!content?.textContent?.trim();
});
const hasTool = !!message.contentEl.querySelector(".pi-agent-tool-block");
for (const block of thinkingBlocks) {
const content = block.querySelector(".pi-agent-thinking-content") as HTMLElement | null;
if (!content?.textContent?.trim()) block.remove();
}
if (!hasText && !hasThinking && !hasTool) {
message.el.remove();
this.renderedMessages = this.renderedMessages.filter((m) => m !== message);
return;
}
message.el.toggleClass("is-tool-only", hasTool && !hasText && !hasThinking);
}
/** Render a single message from a history payload (file or RPC). */
private renderMessageFromHistory(msg: any): void {
if (msg.role === "user") {
const content =
typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? msg.content.map((c: any) => c.text || "").join("")
: "";
this.addMessage("user", this.stripRecentContextGuard(content));
} else if (msg.role === "compactionSummary") {
this.addCompactionSummaryMessage(msg.summary || "", msg.tokensBefore);
} else if (msg.role === "branchSummary") {
this.addCompactionSummaryMessage(msg.summary || "", undefined, "Branch summary");
} else if (msg.role === "assistant") {
const blocks = Array.isArray(msg.content) ? msg.content : [];
const hasVisibleText = blocks.some((block: any) => block.type === "text" && String(block.text || "").trim());
const hasVisibleThinking = blocks.some((block: any) => block.type === "thinking" && String(block.thinking || "").trim());
const hasToolCall = blocks.some((block: any) => block.type === "toolCall");
if (!hasVisibleText && !hasVisibleThinking && !hasToolCall) return;
const rendered = this.addMessage("assistant", "");
this.currentAssistantMsg = rendered;
if (blocks.length > 0) {
for (const block of blocks) {
if (block.type === "text" && block.text) {
rendered.el.setAttribute("data-raw-content", block.text);
const textBlock =
rendered.contentEl.createDiv("pi-agent-text-block markdown-preview-view markdown-rendered");
void MarkdownRenderer.render(this.app, this.normalizeAssistantMarkdown(block.text), textBlock, "", this);
} else if (block.type === "thinking" && this.plugin.settings.showThinking && String(block.thinking || "").trim()) {
const tb = rendered.contentEl.createDiv("pi-agent-thinking-block is-collapsed");
const header = tb.createDiv("pi-agent-thinking-header");
const iconSpan = header.createSpan("pi-agent-thinking-icon");
setIcon(iconSpan, "brain");
const textSpan = header.createSpan("pi-agent-thinking-text");
textSpan.setText(" Thought");
tb.createDiv("pi-agent-thinking-content").textContent = block.thinking || "";
header.onclick = () => {
tb.toggleClass("is-collapsed", !tb.hasClass("is-collapsed"));
};
} else if (block.type === "toolCall") {
this.handleToolStart({
type: "tool_execution_start",
toolName: block.name,
toolCallId: block.id,
args: block.arguments
});
}
}
}
this.finalizeAssistantMessageVisibility(rendered);
this.currentAssistantMsg = null;
} else if (msg.role === "toolResult") {
this.handleToolEnd({
type: "tool_execution_end",
toolName: msg.toolName,
toolCallId: msg.toolCallId,
isError: msg.isError,
result: {
content: msg.content,
isError: msg.isError,
details: msg.details
}
});
}
}
private updateButtons(): void {
if (this.abortBtn) {
if (this.isStreaming) {
this.abortBtn.removeClass("pi-agent-hidden");
} else {
this.abortBtn.addClass("pi-agent-hidden");
}
}
this.containerEl.toggleClass("is-generating", this.isStreaming);
}
private getBasename(pathText: string): string {
if (!pathText) return "";
const parts = pathText.split(/[/\\]/);
return parts[parts.length - 1] || pathText;
}
private formatToolArgs(
toolName: string,
args: Record<string, unknown>
): string {
switch (toolName) {
case "read": {
const path = (args.path as string) || (args.TargetFile as string) || (args.target as string) || "";
const base = this.getBasename(path);
return `${base}${args.offset ? ` (offset: ${args.offset})` : ""}`;
}
case "bash": {
const cmd = (args.command as string) || "";
return cmd.length > 35 ? cmd.slice(0, 35) + "..." : cmd;
}
case "write": {
const path = (args.path as string) || (args.TargetFile as string) || (args.target as string) || "";
return this.getBasename(path);
}
case "edit": {
const path = (args.path as string) || (args.TargetFile as string) || (args.target as string) || "";
return this.getBasename(path);
}
case "grep":
return `${args.pattern || ""}`;
case "find":
return `${args.pattern || ""}`;
case "ls": {
return this.getBasename((args.path as string) || ".");
}
default:
return JSON.stringify(args).slice(0, 100);
}
}
private getToolIcon(toolName: string): string {
switch (toolName) {
case "read":
return "◇";
case "write":
return "⊞";
case "edit":
return "✎";
case "bash":
return "⌘";
case "grep":
case "find":
return "⌕";
case "ls":
return "▣";
default:
return "✧";
}
}
private toTitleCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
private renderDiffOutput(container: HTMLElement, diffText: string): void {
const actions = container.createDiv("pi-agent-diff-actions");
const copy = actions.createEl("button", { text: "Copy diff" });
copy.onclick = (event) => {
event.stopPropagation();
void navigator.clipboard.writeText(diffText).then(() => {
new Notice("Diff copied");
}).catch((err: unknown) => {
console.error("[pimate] copy diff failed", err);
});
};
const pre = container.createEl("pre", { cls: "pi-agent-diff-pre" });
const shown = diffText.length > 12000 ? diffText.slice(0, 12000) + "\n…" : diffText;
for (const line of shown.split("\n")) {
const span = pre.createSpan({ text: `${line}\n` });
if (line.startsWith("+") && !line.startsWith("+++")) span.addClass("pi-agent-diff-line-add");
else if (line.startsWith("-") && !line.startsWith("---")) span.addClass("pi-agent-diff-line-remove");
else if (line.startsWith("@@")) span.addClass("pi-agent-diff-line-hunk");
else if (line.startsWith("diff ") || line.startsWith("+++") || line.startsWith("---")) {
span.addClass("pi-agent-diff-line-meta");
}
}
}
private getDiffText(details: unknown): string {
if (!details || typeof details !== "object") return "";
const maybeDetails = details as Record<string, unknown>;
return typeof maybeDetails.patch === "string"
? maybeDetails.patch
: typeof maybeDetails.diff === "string"
? maybeDetails.diff
: "";
}
private getDiffStats(
details: unknown
): { added: number; removed: number } | null {
const diff = this.getDiffText(details);
if (!diff) return null;
let added = 0;
let removed = 0;
for (const line of diff.split("\n")) {
if (line.startsWith("+++") || line.startsWith("---")) continue;
if (line.startsWith("+")) added++;
if (line.startsWith("-")) removed++;
}
return added || removed ? { added, removed } : null;
}
async onClose(): Promise<void> {
if (this.thinkingTimer) {
window.clearInterval(this.thinkingTimer);
this.thinkingTimer = null;
}
if (this.renderTimeout) {
window.clearTimeout(this.renderTimeout);
this.renderTimeout = null;
}
if (this.speedTimer) {
window.clearInterval(this.speedTimer);
this.speedTimer = null;
}
if (this.speedHideTimer) {
window.clearTimeout(this.speedHideTimer);
this.speedHideTimer = null;
}
await this.persistSessionTabs();
for (const tab of this.tabs) {
await tab.client?.destroy();
tab.client = null;
}
this.client = null;
}
// ─── Stream Render Methods ──────────────────────────────────────────
private shouldUsePrettyStreaming(rawLength: number): boolean {
const mode = this.plugin.settings.streamingRenderMode || "auto";
if (mode === "pretty") return true;
if (mode === "fast") return false;
// Auto: stay in cheap fast streaming while tokens are flowing; an idle
// debounce in throttleRender() promotes the accumulated text to pretty
// when the model pauses. Pretty is also force-flushed at text_end /
// message_end so we never leave a reply half-decorated.
return false;
}
private isAutoStreamingMode(): boolean {
const mode = this.plugin.settings.streamingRenderMode || "auto";
return mode === "auto";
}
private convertCurrentTextBlockToFastStreaming(): void {
if (!this.currentTextBlock) return;
this.currentTextBlock.classList.remove("markdown-preview-view", "markdown-rendered");
this.currentTextBlock.classList.add("pi-agent-streaming-block");
this.currentTextBlock.empty();
this.streamingTextEl = this.currentTextBlock.createDiv("pi-agent-streaming-text");
this.streamingCursorEl = this.currentTextBlock.createSpan("pi-agent-streaming-cursor");
}
private throttleRender(rawText: string, targetEl: HTMLElement): void {
if (this.renderTimeout) {
window.clearTimeout(this.renderTimeout);
this.renderTimeout = null;
}
// In auto mode we behave like an idle debounce: keep fast text on screen,
// and only promote to pretty MarkdownRenderer when the model pauses.
// Pretty throttles on a 150ms cadence (was 80ms) to leave the main thread
// some breathing room on long replies. Fast mode is unaffected.
const isAuto = this.isAutoStreamingMode();
const delay = isAuto ? 140 : 150;
const now = Date.now();
if (now - this.lastRenderTime >= delay) {
this.renderMarkdownWithCursor(rawText, targetEl);
this.lastRenderTime = now;
} else {
this.renderTimeout = window.setTimeout(() => {
this.renderMarkdownWithCursor(rawText, targetEl);
this.lastRenderTime = Date.now();
}, delay - (now - this.lastRenderTime));
}
}
// Lightweight appender used during streaming. We only set the textContent of
// two <div>/<span> nodes — no MarkdownRenderer pass, no DOM re-build, no
// markdown re-parse. The final MarkdownRenderer.render() happens once at
// message_end in handleMessageEnd().
//
// In auto mode we additionally promote the in-flight fast text to pretty
// Markdown as soon as the model emits a newline, so each completed
// paragraph / list item / table row is rendered with full formatting
// exactly once, without forcing a 140ms idle wait.
private appendStreamingDelta(rawText: string, deltaText: string): void {
if (this.renderTimeout) {
window.clearTimeout(this.renderTimeout);
this.renderTimeout = null;
}
const now = Date.now();
const delay = 50;
const apply = () => {
const shouldStickToBottom = this.isNearBottom();
if (this.streamingTextEl) {
this.streamingTextEl.textContent = rawText;
}
if (shouldStickToBottom) this.scrollToBottom(true, true);
this.lastRenderTime = Date.now();
};
if (now - this.lastRenderTime >= delay) {
apply();
} else {
this.renderTimeout = window.setTimeout(
apply,
delay - (now - this.lastRenderTime)
);
}
if (
this.isAutoStreamingMode() &&
deltaText &&
deltaText.includes("\n") &&
!this.isInsideUnclosedFence(rawText)
) {
this.flushPrettyIfNeeded();
}
}
private isInsideUnclosedFence(text: string): boolean {
// Treat a line as a fenced code block delimiter only when the marker
// occupies the whole line (after optional indentation). This avoids
// mis-counting lines like `open \`\`\` here` or `\`\`\`` embedded in
// prose as fence openers/closers.
let inFence = false;
let fenceChar = "";
let fenceLen = 0;
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^\s*(`{3,}|~{3,})\s*([^\s`]*)\s*$/);
if (!match) continue;
const marker = match[1];
const markerChar = marker[0];
if (!inFence) {
inFence = true;
fenceChar = markerChar;
fenceLen = marker.length;
} else if (markerChar === fenceChar && marker.length >= fenceLen) {
inFence = false;
fenceChar = "";
fenceLen = 0;
}
}
return inFence;
}
// Promote the in-flight fast text to pretty Markdown right now. Used by
// auto mode at text_end / message_end to guarantee the user never sees an
// unrendered reply.
private flushPrettyIfNeeded(): void {
if (!this.isAutoStreamingMode()) return;
if (!this.currentTextBlock || !this.streamingTextEl) return;
const raw = this.currentTextBlock.getAttribute("data-stream-raw")
|| this.streamingTextEl.textContent
|| "";
if (!raw) return;
this.renderMarkdownWithCursor(raw, this.currentTextBlock);
}
private normalizeAssistantMarkdown(text: string): string {
if (!text) return text;
const codeBlocks: string[] = [];
const placeholderPrefix = "@@PIMATE_CODE_BLOCK_";
const protectedText = text.replace(/```[\s\S]*?```/g, (block) => {
const key = `${placeholderPrefix}${codeBlocks.length}@@`;
codeBlocks.push(block);
return key;
});
const normalized = protectedText
// Fix: "文字###标题" -> "文字\n\n### 标题".
.replace(/([^\n])([ \t]*#{2,6})(?=[\p{L}\p{N}])/gu, (_m, before, hashes) => {
return `${before}\n\n${hashes.trim()} `;
})
// Fix: "###A." / "###第1步" -> "### A." / "### 第1步".
.replace(/^(#{1,6})(?!\s)([^#\s].*)$/gmu, (_m, hashes, rest) => {
return `${hashes} ${rest}`;
})
// Fix: "### C.暂停" -> "### C. 暂停".
.replace(/^(#{1,6}\s+[A-Za-z]\.)(?=\S)/gmu, "$1 ");
return normalized.replace(
new RegExp(`${placeholderPrefix}(\\d+)@@`, "g"),
(_m, index) => codeBlocks[Number(index)] || ""
);
}
private renderMarkdownWithCursor(rawText: string, targetEl: HTMLElement): void {
const shouldStickToBottom = this.isNearBottom();
targetEl.empty();
const normalizedText = this.normalizeAssistantMarkdown(rawText);
const inCodeblock = this.isInsideUnclosedFence(normalizedText);
const cursor = inCodeblock ? " ▊" : ' <span class="pi-agent-typing-cursor">▊</span>';
const textWithCursor = normalizedText + cursor;
const finalRenderText = inCodeblock ? textWithCursor + "\n```" : textWithCursor;
void MarkdownRenderer.render(
this.app,
finalRenderText,
targetEl,
"",
this
).then(() => {
if (shouldStickToBottom) this.scrollToBottom(true, true);
});
}
// ─── Autocomplete Mention Methods ───────────────────────────────────
private handleMentionInput(): void {
if (!this.inputEl) return;
const value = this.inputEl.value;
const caretPos = this.inputEl.selectionStart;
let atIndex = -1;
for (let i = caretPos - 1; i >= 0; i--) {
const char = value[i];
if (char === " " || char === "\n") {
break;
}
if (char === "@") {
if (i === 0 || value[i - 1] === " " || value[i - 1] === "\n") {
atIndex = i;
break;
}
}
}
if (atIndex !== -1) {
const query = value.slice(atIndex + 1, caretPos).toLowerCase();
this.mentionQueryStart = atIndex;
this.showMentionDropdown(query);
} else {
this.closeMentionDropdown();
}
}
private showMentionDropdown(query: string): void {
if (!this.inputEl) return;
if (!this.mentionDropdown) {
const inputArea = this.inputEl.parentElement;
if (!inputArea) return;
this.mentionDropdown = inputArea.createDiv({ cls: "pi-agent-mention-dropdown" });
}
const q = query.toLowerCase();
const files = this.getAttachableFiles();
const folders: TFolder[] = (this.app.vault as any).getAllFolders
? (this.app.vault as any).getAllFolders()
: this.collectAllFolders();
const fileEntries = files
.map((file) => this.createMentionEntry("file", file, q))
.filter((entry): entry is MentionEntry => entry !== null);
const folderEntries = folders
.map((folder) => this.createMentionEntry("folder", folder, q))
.filter((entry): entry is MentionEntry => entry !== null);
this.filteredMentionFiles = [...folderEntries, ...fileEntries]
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.path.length !== b.path.length) return a.path.length - b.path.length;
return a.path.localeCompare(b.path);
})
.slice(0, 20);
this.renderMentionDropdownItems();
}
private createMentionEntry(
kind: "file" | "folder",
item: TFile | TFolder,
query: string
): MentionEntry | null {
const path = item.path || "/";
const name = item instanceof TFile ? item.basename : item.name || path;
const score = this.scoreMentionMatch(name, path, query);
if (score <= 0) return null;
const activeFile = this.app.workspace.getActiveFile();
const activeFileBonus = item instanceof TFile && activeFile?.path === item.path ? 100 : 0;
const typeBonus = kind === "folder" ? 20 : 0;
return {
kind,
file: item instanceof TFile ? item : undefined,
folder: item instanceof TFolder ? item : undefined,
score: score + activeFileBonus + typeBonus,
path,
name,
};
}
private scoreMentionMatch(name: string, path: string, query: string): number {
if (!query) return 100;
const q = query.toLowerCase();
const n = name.toLowerCase();
const p = path.toLowerCase();
if (n === q || p === q) return 1000;
if (n.startsWith(q)) return 800;
if (p.startsWith(q)) return 700;
if (n.includes(q)) return 600;
if (p.includes(q)) return 400;
return 0;
}
private collectAllFolders(): TFolder[] {
const out: TFolder[] = [];
const root = this.app.vault.getRoot();
const walk = (folder: TFolder) => {
out.push(folder);
for (const child of folder.children ?? []) {
if (child instanceof TFolder) walk(child);
}
};
if (root) walk(root);
return out;
}
private renderMentionDropdownItems(): void {
if (!this.mentionDropdown) return;
this.mentionDropdown.empty();
if (this.filteredMentionFiles.length === 0) {
this.closeMentionDropdown();
return;
}
this.activeMentionIndex = Math.min(
this.activeMentionIndex,
this.filteredMentionFiles.length - 1
);
if (this.activeMentionIndex < 0) this.activeMentionIndex = 0;
this.filteredMentionFiles.forEach((entry, index) => {
const itemEl = this.mentionDropdown!.createDiv({
cls: `pi-agent-mention-item ${index === this.activeMentionIndex ? "is-active" : ""}`,
});
const icon =
entry.kind === "folder"
? "📁"
: this.getFileTypeIcon(entry.file!.extension);
const label = entry.kind === "folder" ? entry.path : entry.name;
const subLabel = entry.kind === "folder" ? "" : entry.path;
itemEl.createSpan({ text: icon + " ", cls: "pi-agent-mention-item-icon" });
const textEl = itemEl.createDiv({ cls: "pi-agent-mention-item-text" });
textEl.createSpan({ text: label, cls: "pi-agent-mention-item-name" });
if (subLabel && subLabel !== label) {
textEl.createSpan({ text: subLabel, cls: "pi-agent-mention-item-path" });
}
itemEl.onclick = (e) => {
e.stopPropagation();
this.activeMentionIndex = index;
this.insertMentionSelection();
};
});
}
private insertMentionSelection(): void {
if (!this.inputEl || this.mentionQueryStart === -1) return;
const entry = this.filteredMentionFiles[this.activeMentionIndex];
if (!entry) return;
const value = this.inputEl.value;
const caretPos = this.inputEl.selectionStart;
const before = value.slice(0, this.mentionQueryStart);
const after = value.slice(caretPos);
let mentionText: string;
if (entry.kind === "folder") {
const folder = entry.folder;
if (!folder) return;
mentionText = `[[${(folder.path || "/")}/]]`;
this.addFolderContextItem(folder, false);
} else {
const file = entry.file;
if (!file) return;
mentionText = `[[${file.basename}]]`;
this.addFileContextItem(file);
}
this.inputEl.value = before + mentionText + " " + after;
const newCaretPos = this.mentionQueryStart + mentionText.length + 1;
this.inputEl.setSelectionRange(newCaretPos, newCaretPos);
this.closeMentionDropdown();
this.inputEl.focus();
}
private closeMentionDropdown(): void {
if (this.mentionDropdown) {
this.mentionDropdown.remove();
this.mentionDropdown = null;
}
this.mentionQueryStart = -1;
this.filteredMentionFiles = [];
this.activeMentionIndex = 0;
}
// ─── Autocomplete Slash Command Methods ─────────────────────────────
private async loadAvailableCommands(): Promise<void> {
if (!this.client) return;
try {
const res = await this.client.getCommands();
if (res.success && res.data) {
this.availableCommands = ((res.data as any).commands || []) as PiCommand[];
}
} catch {}
}
private handleCommandInput(): void {
if (!this.inputEl) return;
const value = this.inputEl.value;
const caretPos = this.inputEl.selectionStart;
if (value.startsWith("/") && caretPos > 0 && !value.slice(0, caretPos).includes(" ")) {
const query = value.slice(1, caretPos).toLowerCase();
this.commandQueryStart = 0;
this.showCommandDropdown(query);
} else {
this.closeCommandDropdown();
}
}
private showCommandDropdown(query: string): void {
if (!this.inputEl) return;
if (!this.commandDropdown) {
const inputArea = this.inputEl.parentElement;
if (!inputArea) return;
this.commandDropdown = inputArea.createDiv({ cls: "pi-agent-command-dropdown" });
}
this.filteredCommands = this.availableCommands
.filter((cmd) => cmd.name.toLowerCase().includes(query) || (cmd.description && cmd.description.toLowerCase().includes(query)))
.slice(0, 5);
this.renderCommandDropdownItems();
}
private renderCommandDropdownItems(): void {
if (!this.commandDropdown) return;
this.commandDropdown.empty();
if (this.filteredCommands.length === 0) {
this.closeCommandDropdown();
return;
}
this.activeCommandIndex = Math.min(
this.activeCommandIndex,
this.filteredCommands.length - 1
);
if (this.activeCommandIndex < 0) this.activeCommandIndex = 0;
this.filteredCommands.forEach((cmd, index) => {
const itemEl = this.commandDropdown!.createDiv({
cls: `pi-agent-command-item ${index === this.activeCommandIndex ? "is-active" : ""}`,
});
itemEl.createSpan({ text: "⚡ ", cls: "pi-agent-command-item-icon" });
itemEl.createSpan({ text: `/${cmd.name}`, cls: "pi-agent-command-item-name" });
if (cmd.description) {
itemEl.createSpan({ text: ` - ${cmd.description}`, cls: "pi-agent-command-item-desc" });
}
itemEl.onclick = (e) => {
e.stopPropagation();
this.activeCommandIndex = index;
this.insertCommandSelection();
};
});
}
private insertCommandSelection(): void {
if (!this.inputEl || this.commandQueryStart === -1) return;
const cmd = this.filteredCommands[this.activeCommandIndex];
if (!cmd) return;
const value = this.inputEl.value;
const caretPos = this.inputEl.selectionStart;
const before = value.slice(0, this.commandQueryStart);
const after = value.slice(caretPos);
const commandText = `/${cmd.name}`;
this.inputEl.value = before + commandText + " " + after;
const newCaretPos = this.commandQueryStart + commandText.length + 1;
this.inputEl.setSelectionRange(newCaretPos, newCaretPos);
this.closeCommandDropdown();
this.resizeInputEl();
this.inputEl.focus();
}
private closeCommandDropdown(): void {
if (this.commandDropdown) {
this.commandDropdown.remove();
this.commandDropdown = null;
}
this.commandQueryStart = -1;
this.filteredCommands = [];
this.activeCommandIndex = 0;
}
private getThinkingLevelLabel(level: string): string {
const v = level?.toLowerCase() || "";
switch (v) {
case "":
case "auto":
return "Auto";
case "off":
return "Off";
case "minimal":
return "Minimal";
case "low":
return "Low";
case "medium":
return "Medium";
case "high":
return "High";
case "xhigh":
return "XHigh";
case "max":
return "Max";
default:
// Unknown level from Pi — render the raw id so the user can see
// exactly what the model is using, instead of masking it as "Auto".
return level || "Auto";
}
}
private async showThinkingLevelSelector(): Promise<void> {
const isZh = this.plugin.settings.language === "zh";
const tab = this.activeTab;
if (tab?.client) {
await this.syncTabStateFromPi(tab);
}
const { options, note } = this.buildThinkingLevelOptions(tab);
let modalOptions: ThinkingLevelOption[];
if (options.length === 0) {
new Notice(note ?? (isZh ? "当前模型没有可选思考档位" : "No selectable thinking level for current model"));
return;
} else {
modalOptions = options.map((opt) => ({
id: opt.id,
name: `${opt.name} (${opt.id})`,
desc: opt.desc,
}));
}
new ThinkingLevelSuggestModal(this.app, modalOptions, isZh, async (option) => {
await this.updateActiveTabThinkingLevel(option.id);
new Notice(isZh ? `思考强度已设为 ${option.name}` : `Thinking level set to ${option.name}`);
}).open();
}
public async setupStaticTabs(): Promise<void> {
const maxTabs = this.plugin.settings.maxTabs || 3;
if (this.tabs.length > maxTabs) {
this.tabs = this.tabs.slice(0, maxTabs);
} else {
while (this.tabs.length < maxTabs) {
const i = this.tabs.length + 1;
this.tabs.push({
id: `tab-static-${i}`,
label: String(i),
client: null,
isStreaming: false,
});
}
}
this.restoreTabModelConfig();
if (!this.tabs.some((t) => t.id === this.activeTabId)) {
this.activeTabId = this.tabs[0]?.id || null;
}
this.renderTabs();
if (this.activeTabId) {
await this.switchToTab(this.activeTabId);
}
}
private activeDropdown: "model" | "effort" | null = null;
private activeDropdownEl: HTMLElement | null = null;
private availableModelsCache: PiModel[] | null = null;
private modelOutsideClickHandler: ((e: MouseEvent) => void) | null = null;
private effortOutsideClickHandler: ((e: MouseEvent) => void) | null = null;
private getModelShortName(modelId: string): string {
const lower = modelId.toLowerCase();
if (lower.includes("opus")) return "Opus";
if (lower.includes("sonnet")) return "Sonnet";
if (lower.includes("haiku")) return "Haiku";
if (lower.includes("deepseek")) {
if (lower.includes("reasoner") || lower.includes("r1")) return "DeepSeek-R1";
if (lower.includes("chat") || lower.includes("v3")) return "DeepSeek-V3";
const last = modelId.split("/").pop() || modelId;
if (last.toLowerCase() === "deepseek") return "DeepSeek";
return last.split("-").map(part => part.charAt(0).toUpperCase() + part.slice(1)).join("-");
}
if (lower.includes("gemini")) return "Gemini";
if (lower.includes("gpt-4o")) return "GPT-4o";
if (lower.includes("o1")) return "o1";
if (lower.includes("o3")) return "o3";
return modelId.split("/").pop() || modelId;
}
private getProviderIconName(provider: string, modelId: string): string {
const p = provider.toLowerCase();
const m = modelId.toLowerCase();
if (p.includes("xiaomi") || p.includes("小米") || m.includes("xiaomi") || m.includes("milm")) return "pi-agent-icon-xiaomi";
if (p.includes("openai") || p.includes("gpt") || m.includes("gpt")) return "pi-agent-icon-openai";
if (p.includes("anthropic") || p.includes("claude") || m.includes("claude")) return "pi-agent-icon-claude";
if (p.includes("deepseek") || m.includes("deepseek")) return "pi-agent-icon-deepseek";
if (p.includes("minimax") || m.includes("minimax")) return "pi-agent-icon-minimax";
if (p.includes("google") || p.includes("gemini") || m.includes("gemini")) return "pi-agent-icon-gemini";
if (p.includes("volcengine") || p.includes("doubao") || p.includes("seed") || m.includes("doubao") || m.includes("seed")) return "pi-agent-icon-volcengine";
if (p.includes("siliconflow") || p.includes("siliconcloud") || m.includes("siliconflow") || m.includes("siliconcloud")) return "pi-agent-icon-siliconflow";
if (p.includes("zhipu") || p.includes("智谱") || m.includes("glm") || m.includes("zhipu")) return "pi-agent-icon-zhipu";
return "pi-agent-icon-claude";
}
}
class CommandSuggestModal extends SuggestModal<PiCommand> {
constructor(
app: App,
private readonly commands: PiCommand[],
private readonly onChoose: (command: PiCommand) => void
) {
super(app);
this.setPlaceholder("Search commands and skills...");
}
getSuggestions(query: string): PiCommand[] {
const q = query.toLowerCase().trim();
if (!q) return this.commands.slice(0, 80);
return this.commands
.filter((command) =>
`${command.name} ${command.description || ""} ${command.source || ""}`
.toLowerCase()
.includes(q)
)
.slice(0, 80);
}
renderSuggestion(command: PiCommand, el: HTMLElement): void {
el.addClass("pi-agent-suggestion");
el.createDiv({
text: `/${command.name}`,
cls: "pi-agent-suggestion-title",
});
el.createDiv({
text: `${command.source || "command"}${command.description ? ` · ${command.description}` : ""}`,
cls: "pi-agent-suggestion-note",
});
}
onChooseSuggestion(command: PiCommand): void {
this.onChoose(command);
}
}
class ResumeSessionSuggestModal extends SuggestModal<ResumeSessionItem> {
constructor(
app: App,
private readonly sessions: ResumeSessionItem[],
private readonly onChoose: (session: ResumeSessionItem) => void | Promise<void>
) {
super(app);
this.setPlaceholder("Resume which Pi session?");
}
getSuggestions(query: string): ResumeSessionItem[] {
const q = query.toLowerCase().trim();
if (!q) return this.sessions.slice(0, 80);
return this.sessions
.filter((session) => `${session.label} ${session.preview || ""} ${session.path}`.toLowerCase().includes(q))
.slice(0, 80);
}
renderSuggestion(session: ResumeSessionItem, el: HTMLElement): void {
el.addClass("pi-agent-suggestion");
el.createDiv({
text: session.label || basename(session.path),
cls: "pi-agent-suggestion-title",
});
const date = new Date(session.mtime).toLocaleString();
el.createDiv({
text: `${date} · ${session.preview || session.path}`,
cls: "pi-agent-suggestion-note",
});
}
onChooseSuggestion(session: ResumeSessionItem): void {
void Promise.resolve(this.onChoose(session)).catch((err: unknown) => {
console.error("[pimate] resume session selection failed", err);
});
}
}
class ResumeActionModal extends Modal {
constructor(
app: App,
private readonly session: ResumeSessionItem,
private readonly done: (action: "open" | "delete" | "cancel") => void | Promise<void>
) {
super(app);
this.titleEl.setText("Pimate session");
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("pi-agent-editor-modal");
contentEl.createDiv({ text: this.session.label, cls: "pi-agent-suggestion-title" });
contentEl.createDiv({ text: this.session.path, cls: "pi-agent-suggestion-note" });
if (this.session.preview) {
const pre = contentEl.createEl("pre", { cls: "pi-agent-context-preview-text" });
pre.setText(this.session.preview);
}
const buttons = contentEl.createDiv("pi-agent-editor-modal-buttons");
const cancel = buttons.createEl("button", { text: "Cancel" });
const del = buttons.createEl("button", { text: "Delete" });
const open = buttons.createEl("button", { text: "Open", cls: "mod-cta" });
cancel.onclick = () => {
void Promise.resolve(this.done("cancel")).catch((err: unknown) => console.error("[pimate] resume action failed", err));
this.close();
};
del.onclick = () => {
void Promise.resolve(this.done("delete")).catch((err: unknown) => console.error("[pimate] resume action failed", err));
this.close();
};
open.onclick = () => {
void Promise.resolve(this.done("open")).catch((err: unknown) => console.error("[pimate] resume action failed", err));
this.close();
};
}
onClose(): void {
this.contentEl.empty();
}
}
class ForkMessageSuggestModal extends SuggestModal<ForkMessage> {
constructor(
app: App,
private readonly messages: ForkMessage[],
private readonly onChoose: (message: ForkMessage) => void | Promise<void>
) {
super(app);
this.setPlaceholder("Fork from which previous prompt?");
}
getSuggestions(query: string): ForkMessage[] {
const q = query.toLowerCase().trim();
if (!q) return this.messages.slice().reverse().slice(0, 80);
return this.messages
.filter((message) => message.text.toLowerCase().includes(q))
.reverse()
.slice(0, 80);
}
renderSuggestion(message: ForkMessage, el: HTMLElement): void {
el.addClass("pi-agent-suggestion");
el.createDiv({
text: message.text.split("\n")[0].slice(0, 90) || "Untitled prompt",
cls: "pi-agent-suggestion-title",
});
el.createDiv({
text: message.entryId,
cls: "pi-agent-suggestion-note",
});
}
onChooseSuggestion(message: ForkMessage): void {
void Promise.resolve(this.onChoose(message)).catch((err: unknown) => {
console.error("[pimate] fork message selection failed", err);
});
}
}
class ModelSuggestModal extends SuggestModal<PiModel> {
constructor(
app: App,
private readonly models: PiModel[],
private readonly onChoose: (model: PiModel) => void | Promise<void>
) {
super(app);
this.setPlaceholder("Search model, e.g. deepseek / claude / gpt...");
}
getSuggestions(query: string): PiModel[] {
const q = query.toLowerCase().trim();
if (!q) return this.models.slice(0, 80);
return this.models
.filter((model) =>
`${model.provider}/${model.id} ${model.name || ""}`
.toLowerCase()
.includes(q)
)
.slice(0, 80);
}
renderSuggestion(model: PiModel, el: HTMLElement): void {
el.addClass("pi-agent-suggestion");
el.createDiv({
text: model.name || model.id,
cls: "pi-agent-suggestion-title",
});
el.createDiv({
text: `${model.provider}/${model.id}`,
cls: "pi-agent-suggestion-note",
});
}
onChooseSuggestion(model: PiModel): void {
void Promise.resolve(this.onChoose(model)).catch((err: unknown) => {
console.error("[pimate] model selection failed", err);
});
}
}
class FileSuggestModal extends SuggestModal<TFile> {
constructor(
app: App,
private readonly files: TFile[],
private readonly onChoose: (file: TFile) => void
) {
super(app);
this.setPlaceholder("Search file to attach as @context...");
}
getSuggestions(query: string): TFile[] {
const q = query.toLowerCase().trim();
if (!q) return this.files.slice(0, 80);
return this.files
.filter((file) => file.path.toLowerCase().includes(q))
.slice(0, 80);
}
renderSuggestion(file: TFile, el: HTMLElement): void {
el.addClass("pi-agent-suggestion");
el.createDiv({ text: file.basename, cls: "pi-agent-suggestion-title" });
el.createDiv({ text: file.path, cls: "pi-agent-suggestion-note" });
}
onChooseSuggestion(file: TFile): void {
this.onChoose(file);
}
}
class PiAgentSelectModal extends SuggestModal<string> {
private answered = false;
constructor(
app: App,
private readonly modalTitle: string,
private readonly options: string[],
private readonly done: (value: string | null) => void
) {
super(app);
this.setPlaceholder(modalTitle || "Select an option");
}
getSuggestions(query: string): string[] {
const q = query.toLowerCase();
return this.options.filter((option) => option.toLowerCase().includes(q));
}
renderSuggestion(value: string, el: HTMLElement): void {
el.setText(value);
}
onChooseSuggestion(value: string): void {
this.answered = true;
this.done(value);
}
onClose(): void {
super.onClose();
if (!this.answered) this.done(null);
}
}
class PiAgentInputModal extends Modal {
private answered = false;
private inputEl!: HTMLInputElement;
constructor(
app: App,
title: string,
private readonly placeholder: string,
private readonly done: (value: string | null) => void
) {
super(app);
this.titleEl.setText(title || "Pimate input");
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("pi-agent-editor-modal");
this.inputEl = contentEl.createEl("input", {
cls: "pi-agent-input-modal-input",
attr: { type: "text", placeholder: this.placeholder },
});
this.inputEl.value = this.placeholder || "";
this.inputEl.focus();
this.inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") this.submit();
if (e.key === "Escape") this.cancel();
});
const buttons = contentEl.createDiv("pi-agent-editor-modal-buttons");
buttons.createEl("button", { text: "Cancel" }).onclick = () => this.cancel();
buttons.createEl("button", { text: "OK", cls: "mod-cta" }).onclick = () => this.submit();
}
private submit(): void {
this.answered = true;
this.done(this.inputEl.value);
this.close();
}
private cancel(): void {
this.answered = true;
this.done(null);
this.close();
}
onClose(): void {
this.contentEl.empty();
if (!this.answered) this.done(null);
}
}
class PiAgentConfirmModal extends Modal {
private answered = false;
constructor(
app: App,
title: string,
private readonly message: string,
private readonly done: (confirmed: boolean) => void
) {
super(app);
this.titleEl.setText(title || "Pimate confirmation");
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("pi-agent-editor-modal");
const pre = contentEl.createEl("pre", { cls: "pi-agent-context-preview-text" });
pre.setText(this.message || "Allow this action?");
const buttons = contentEl.createDiv("pi-agent-editor-modal-buttons");
const deny = buttons.createEl("button", { text: "Deny" });
const allow = buttons.createEl("button", { text: "Allow", cls: "mod-cta" });
deny.onclick = () => {
this.answered = true;
this.done(false);
this.close();
};
allow.onclick = () => {
this.answered = true;
this.done(true);
this.close();
};
}
onClose(): void {
this.contentEl.empty();
if (!this.answered) this.done(false);
}
}
class ContextPreviewModal extends Modal {
constructor(app: App, private readonly item: ContextItem) {
super(app);
this.titleEl.setText(this.item.label || "Context preview");
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("pi-agent-context-preview-modal");
if (this.item.type === "image") {
contentEl.createEl("img", {
cls: "pi-agent-context-preview-image",
attr: { src: `data:${this.item.mimeType || "image/png"};base64,${this.item.value}` },
});
return;
}
const pre = contentEl.createEl("pre", { cls: "pi-agent-context-preview-text" });
pre.setText(this.item.value);
}
onClose(): void {
this.contentEl.empty();
}
}
class PiAgentInlineEditModal extends Modal {
private readonly done: (value: string | null) => void;
private submitted = false;
constructor(app: App, done: (value: string | null) => void) {
super(app);
this.titleEl.setText("Inline edit with Pimate");
this.done = done;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("pi-agent-editor-modal");
contentEl.createDiv({
text: "Describe how Pimate should rewrite the selected text.",
cls: "pi-agent-suggestion-note",
});
const textarea = contentEl.createEl("textarea", {
cls: "pi-agent-editor-modal-textarea",
attr: { placeholder: "Make it clearer, shorter, more direct..." },
});
textarea.addClass("pi-agent-textarea-min-height");
textarea.focus();
const buttons = contentEl.createDiv("pi-agent-editor-modal-buttons");
const cancel = buttons.createEl("button", { text: "Cancel" });
const submit = buttons.createEl("button", {
text: "Apply",
cls: "mod-cta",
});
cancel.onclick = () => {
this.submitted = true;
this.done(null);
this.close();
};
submit.onclick = () => {
this.submitted = true;
this.done(textarea.value.trim() || null);
this.close();
};
}
onClose(): void {
this.contentEl.empty();
if (!this.submitted) this.done(null);
}
}
class PiAgentInlineEditReviewModal extends Modal {
private readonly done: (result: InlineEditReviewResult) => void;
private answered = false;
constructor(
app: App,
private readonly original: string,
private readonly replacement: string,
done: (result: InlineEditReviewResult) => void
) {
super(app);
this.titleEl.setText("Review Pimate inline edit");
this.done = done;
}
private renderSimpleDiff(container: HTMLElement, original: string, replacement: string): void {
container.empty();
const lines = this.computeSimpleDiff(original, replacement);
for (let idx = 0; idx < lines.length; idx++) {
const line = lines[idx];
container.createSpan({ text: line.text, cls: line.cls });
if (idx < lines.length - 1) container.appendText("\n");
}
}
private computeSimpleDiff(original: string, replacement: string): Array<{ text: string; cls?: string }> {
const origLines = original.split("\n");
const replLines = replacement.split("\n");
const dp: number[][] = Array(origLines.length + 1)
.fill(null)
.map(() => Array(replLines.length + 1).fill(0));
for (let i = 1; i <= origLines.length; i++) {
for (let j = 1; j <= replLines.length; j++) {
if (origLines[i - 1] === replLines[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
let i = origLines.length;
let j = replLines.length;
const result: Array<{ text: string; cls?: string }> = [];
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && origLines[i - 1] === replLines[j - 1]) {
result.unshift({ text: origLines[i - 1] });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.unshift({ text: `+ ${replLines[j - 1]}`, cls: "pi-diff-ins" });
j--;
} else {
result.unshift({ text: `- ${origLines[i - 1]}`, cls: "pi-diff-del" });
i--;
}
}
return result;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("pi-agent-editor-modal");
contentEl.createDiv({
text: "Changes Preview (红绿差异比对)",
cls: "pi-agent-suggestion-title",
});
const diffContainer = contentEl.createEl("pre", {
cls: "pi-agent-diff-view-pre",
});
this.renderSimpleDiff(diffContainer, this.original, this.replacement);
contentEl.createDiv({
text: "Edit Replacement (可选:微调修改文)",
cls: "pi-agent-suggestion-title",
});
const replacementBox = contentEl.createEl("textarea", {
cls: "pi-agent-editor-modal-textarea",
});
replacementBox.value = this.replacement;
replacementBox.addClass("pi-agent-textarea-min-height");
replacementBox.focus();
// Live update diff view when editing replacement text
replacementBox.addEventListener("input", () => {
this.renderSimpleDiff(diffContainer, this.original, replacementBox.value);
});
const buttons = contentEl.createDiv("pi-agent-editor-modal-buttons");
const reject = buttons.createEl("button", { text: "Reject" });
const regenerate = buttons.createEl("button", { text: "Regenerate" });
const apply = buttons.createEl("button", {
text: "Apply",
cls: "mod-cta",
});
reject.onclick = () => {
this.answered = true;
this.done({ action: "reject" });
this.close();
};
regenerate.onclick = () => {
this.answered = true;
this.done({ action: "regenerate" });
this.close();
};
apply.onclick = () => {
this.answered = true;
this.done({ action: "apply", replacement: replacementBox.value });
this.close();
};
}
onClose(): void {
this.contentEl.empty();
if (!this.answered) this.done({ action: "reject" });
}
}
class PiAgentEditorModal extends Modal {
private value: string;
private readonly done: (value: string | null) => void;
private submitted = false;
constructor(
app: App,
title: string,
prefill: string,
done: (value: string | null) => void
) {
super(app);
this.titleEl.setText(title || "Edit response");
this.value = prefill;
this.done = done;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("pi-agent-editor-modal");
const textarea = contentEl.createEl("textarea", {
cls: "pi-agent-editor-modal-textarea",
});
textarea.value = this.value;
textarea.focus();
const buttons = contentEl.createDiv("pi-agent-editor-modal-buttons");
const cancel = buttons.createEl("button", { text: "Cancel" });
const submit = buttons.createEl("button", {
text: "Submit",
cls: "mod-cta",
});
cancel.onclick = () => {
this.submitted = true;
this.done(null);
this.close();
};
submit.onclick = () => {
this.submitted = true;
this.done(textarea.value);
this.close();
};
}
onClose(): void {
this.contentEl.empty();
if (!this.submitted) this.done(null);
}
}
export interface ThinkingLevelOption {
id: string;
name: string;
desc: string;
}
export class ThinkingLevelSuggestModal extends SuggestModal<ThinkingLevelOption> {
constructor(
app: App,
private readonly options: ThinkingLevelOption[],
private readonly isZh: boolean,
private readonly onChoose: (option: ThinkingLevelOption) => void | Promise<void>
) {
super(app);
this.setPlaceholder(isZh ? "搜索或选择思考强度..." : "Search or select thinking level...");
}
getSuggestions(query: string): ThinkingLevelOption[] {
const q = query.toLowerCase().trim();
if (!q) return this.options;
return this.options.filter(
(opt) =>
opt.name.toLowerCase().includes(q) ||
opt.id.toLowerCase().includes(q) ||
opt.desc.toLowerCase().includes(q)
);
}
renderSuggestion(option: ThinkingLevelOption, el: HTMLElement): void {
el.addClass("pi-agent-suggestion");
el.createDiv({
text: option.name,
cls: "pi-agent-suggestion-title",
});
el.createDiv({
text: option.desc,
cls: "pi-agent-suggestion-note",
});
}
onChooseSuggestion(option: ThinkingLevelOption): void {
void Promise.resolve(this.onChoose(option)).catch((err: unknown) => {
console.error("[pimate] thinking level selection failed", err);
});
}
}
// ─── Usage Stats Modal (mirrors pi-web UsageStats) ────────────────────
type UsageRangePreset =
| "today"
| "yesterday"
| "last7"
| "last30"
| "thisMonth"
| "all"
| "custom";
interface UsageModelRow {
provider: string;
model: string;
messageCount: number;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cacheTotal: number;
totalTokens: number;
cost: number;
hitRate: number | null;
firstUsed: number | null;
lastUsed: number | null;
}
interface UsageTotals {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cacheTotal: number;
totalTokens: number;
cost: number;
messageCount: number;
}
interface UsageResult {
from: number | null;
to: number | null;
sessionCount: number;
byModel: UsageModelRow[];
totals: UsageTotals;
}
type UsageSortKey =
| "messageCount"
| "totalTokens"
| "input"
| "output"
| "cacheRead"
| "cacheWrite"
| "cacheTotal"
| "hitRate"
| "cost";
function startOfLocalDay(d: Date): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function endOfLocalDay(d: Date): Date {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
}
function toLocalInputValue(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fromLocalInputValue(s: string): Date {
return new Date(s);
}
function fmtNum(n: number): string {
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + "B";
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
if (n >= 10_000) return (n / 1000).toFixed(1) + "k";
if (n >= 1000) return (n / 1000).toFixed(2) + "k";
return String(n);
}
function fmtCost(n: number): string {
if (n === 0) return "$0";
if (n < 0.0001) return "<$0.0001";
if (n < 0.01) return "$" + n.toFixed(4);
if (n < 1) return "$" + n.toFixed(3);
return "$" + n.toFixed(2);
}
function fmtDateCompact(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
function computeHitRate(input: number, cacheRead: number): number | null {
const denom = input + cacheRead;
if (denom <= 0) return null;
return cacheRead / denom;
}
function hitRateColor(hr: number | null): string {
if (hr === null) return "var(--text-muted)";
if (hr >= 0.7) return "rgba(34, 197, 94, 0.95)";
if (hr >= 0.3) return "rgba(234, 179, 8, 0.95)";
return "rgba(239, 68, 68, 0.95)";
}
function hitRateLabel(hr: number | null): string {
if (hr === null) return "—";
return (hr * 100).toFixed(1) + "%";
}
function buildRange(
preset: UsageRangePreset,
customFrom: string,
customTo: string
): { from: number | null; to: number | null; label: string } {
const now = new Date();
switch (preset) {
case "today":
return {
from: startOfLocalDay(now).getTime(),
to: endOfLocalDay(now).getTime(),
label: "今天 / Today",
};
case "yesterday": {
const y = new Date(now);
y.setDate(y.getDate() - 1);
return {
from: startOfLocalDay(y).getTime(),
to: endOfLocalDay(y).getTime(),
label: "昨天 / Yesterday",
};
}
case "last7": {
const s = new Date(now);
s.setDate(s.getDate() - 6);
return {
from: startOfLocalDay(s).getTime(),
to: endOfLocalDay(now).getTime(),
label: "最近 7 天 / Last 7 days",
};
}
case "last30": {
const s = new Date(now);
s.setDate(s.getDate() - 29);
return {
from: startOfLocalDay(s).getTime(),
to: endOfLocalDay(now).getTime(),
label: "最近 30 天 / Last 30 days",
};
}
case "thisMonth": {
const s = new Date(now.getFullYear(), now.getMonth(), 1);
return {
from: startOfLocalDay(s).getTime(),
to: endOfLocalDay(now).getTime(),
label: "本月 / This month",
};
}
case "all":
return { from: null, to: null, label: "全部 / All time" };
case "custom": {
const from = customFrom
? fromLocalInputValue(customFrom).getTime()
: startOfLocalDay(now).getTime();
const to = customTo ? fromLocalInputValue(customTo).getTime() : now.getTime();
const f = new Date(from);
const t = new Date(to);
const fmt = (d: Date) => `${d.getMonth() + 1}/${d.getDate()}`;
return { from, to, label: `${fmt(f)} ${fmt(t)}` };
}
}
}
// 一条精简的用量记录(按文件持久化,供增量缓存与范围过滤复用):
// [ts, provider, model, input, output, cacheRead, cacheWrite, total, cost]
type UsageRecord = [number, string, string, number, number, number, number, number, number];
interface UsageCacheFile {
size: number;
mtimeMs: number;
processedLines: number;
records: UsageRecord[];
}
interface UsageCache {
version: number;
perFile: Record<string, UsageCacheFile>;
}
const USAGE_CACHE_VERSION = 1;
function usageCachePath(): string {
return join(homedir(), ".pi", "agent", "usage-cache.json");
}
function loadUsageCache(cachePath: string): UsageCache | null {
try {
if (!existsSync(cachePath)) return null;
const raw = readFileSync(cachePath, "utf8");
const c = JSON.parse(raw) as UsageCache;
if (!c || c.version !== USAGE_CACHE_VERSION || !c.perFile) return null;
return c;
} catch {
return null;
}
}
function saveUsageCache(cachePath: string, cache: UsageCache): void {
try {
writeFileSync(cachePath, JSON.stringify(cache), "utf8");
} catch {
// 缓存写入失败不影响统计结果,下次重建即可。
}
}
// 解析单个会话文件。命中增量时只解析新增行;截断/轮转时全量重建。
function parseUsageFile(
fullPath: string,
cached: UsageCacheFile | undefined,
st: { size: number; mtimeMs: number }
): UsageCacheFile {
let content = "";
try {
content = readFileSync(fullPath, "utf8");
} catch {
return cached ?? { size: 0, mtimeMs: 0, processedLines: 0, records: [] };
}
const lines = content.split(/\r?\n/);
// 末尾换行后的完整行数;未以 \n 结尾的尾行视为未完成,留待下次重读补全。
const completeLines = content.endsWith("\n") ? lines.length - 1 : lines.length;
const truncated = !!cached && st.size < cached.size;
const startLine = cached && !truncated ? cached.processedLines : 0;
const records: UsageRecord[] = cached && !truncated ? cached.records.slice() : [];
for (let i = startLine; i < completeLines; i++) {
const line = lines[i];
if (!line) continue;
let evt: any;
try {
evt = JSON.parse(line);
} catch {
continue;
}
if (evt?.type !== "message") continue;
const msg = evt.message;
if (!msg || msg.role !== "assistant") continue;
const usage = msg.usage;
if (!usage) continue;
const ts = typeof evt.timestamp === "string" ? Date.parse(evt.timestamp) : 0;
const provider = (msg.provider as string) || (evt.provider as string) || "unknown";
const model = (msg.model as string) || (evt.model as string) || "unknown";
const input = Number(usage.input) || 0;
const output = Number(usage.output) || 0;
const cacheRead = Number(usage.cacheRead) || 0;
const cacheWrite = Number(usage.cacheWrite) || 0;
const total =
Number(usage.totalTokens) || input + output + cacheRead + cacheWrite;
const cost = Number(usage.cost?.total) || 0;
records.push([ts, provider, model, input, output, cacheRead, cacheWrite, total, cost]);
}
return { size: st.size, mtimeMs: st.mtimeMs, processedLines: completeLines, records };
}
// 增量扫描mtime+size 未变的文件直接复用缓存记录,只读变化的文件;
// 清理已删除文件;有变更时回写缓存。
function scanUsageIncremental(
sessionsBaseDir: string,
cachePath: string
): UsageCache {
let cache = loadUsageCache(cachePath);
if (!cache) cache = { version: USAGE_CACHE_VERSION, perFile: {} };
let dirty = false;
if (existsSync(sessionsBaseDir)) {
const workspaceDirs = readdirSync(sessionsBaseDir)
.filter((n: string) => n.startsWith("--") && n.endsWith("--"))
.map((n) => join(sessionsBaseDir, n));
for (const wsDir of workspaceDirs) {
let files: string[] = [];
try {
files = readdirSync(wsDir).filter((f) => f.endsWith(".jsonl"));
} catch {
continue;
}
for (const f of files) {
const fullPath = join(wsDir, f);
let st: { size: number; mtimeMs: number };
try {
const s = statSync(fullPath);
st = { size: s.size, mtimeMs: s.mtimeMs };
} catch {
continue;
}
const rel = relative(sessionsBaseDir, fullPath).replace(/\\/g, "/");
const cached = cache.perFile[rel];
if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) {
continue; // 命中缓存,复用 cached.records
}
cache.perFile[rel] = parseUsageFile(fullPath, cached, st);
dirty = true;
}
}
}
// 注意:已从磁盘删除的 session 文件不清理 —— 其 records 作为历史保留,
// 仍参与统计。文件截断/轮转size 变小)由 parseUsageFile 全量重建处理。
if (dirty) saveUsageCache(cachePath, cache);
return cache;
}
// 在已缓存的全量记录上按 [from, to] 过滤并聚合,无需再次读盘。
function aggregateUsage(
perFile: Record<string, UsageCacheFile>,
from: number | null,
to: number | null
): UsageResult {
const byModel = new Map<string, UsageModelRow>();
const totals: UsageTotals = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cacheTotal: 0,
totalTokens: 0,
cost: 0,
messageCount: 0,
};
let sessionCount = 0;
for (const key of Object.keys(perFile)) {
const fileRecords = perFile[key].records;
let touched = false;
for (const r of fileRecords) {
const ts = r[0];
if (ts && ((from != null && ts < from) || (to != null && ts > to))) {
continue;
}
const provider = r[1];
const model = r[2];
const mk = `${provider}::${model}`;
let row = byModel.get(mk);
if (!row) {
row = {
provider,
model,
messageCount: 0,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cacheTotal: 0,
totalTokens: 0,
cost: 0,
hitRate: null,
firstUsed: null,
lastUsed: null,
};
byModel.set(mk, row);
}
const input = r[3];
const output = r[4];
const cacheRead = r[5];
const cacheWrite = r[6];
const total = r[7];
const cost = r[8];
row.messageCount += 1;
row.input += input;
row.output += output;
row.cacheRead += cacheRead;
row.cacheWrite += cacheWrite;
row.cacheTotal += cacheRead + cacheWrite;
row.totalTokens += total;
row.cost += cost;
if (ts) {
if (row.firstUsed == null || ts < row.firstUsed) row.firstUsed = ts;
if (row.lastUsed == null || ts > row.lastUsed) row.lastUsed = ts;
}
totals.input += input;
totals.output += output;
totals.cacheRead += cacheRead;
totals.cacheWrite += cacheWrite;
totals.totalTokens += total;
totals.cost += cost;
totals.messageCount += 1;
touched = true;
}
if (touched) sessionCount += 1;
}
for (const row of byModel.values()) {
row.hitRate = computeHitRate(row.input, row.cacheRead);
}
totals.cacheTotal = totals.cacheRead + totals.cacheWrite;
const list = Array.from(byModel.values()).sort(
(a, b) => b.totalTokens - a.totalTokens
);
return { from, to, sessionCount, byModel: list, totals };
}
function scanUsageRange(
sessionsBaseDir: string,
from: number | null,
to: number | null
): UsageResult {
const cache = scanUsageIncremental(sessionsBaseDir, usageCachePath());
return aggregateUsage(cache.perFile, from, to);
}
class UsageStatsModal extends Modal {
private preset: UsageRangePreset = "last7";
private customFrom: string = toLocalInputValue(
startOfLocalDay(new Date(new Date().setDate(new Date().getDate() - 6)))
);
private customTo: string = toLocalInputValue(new Date());
private data: UsageResult | null = null;
private loading = false;
private error: string | null = null;
private sortKey: UsageSortKey = "totalTokens";
private sortDir: "asc" | "desc" = "desc";
private bodyEl: HTMLElement | null = null;
private summaryEl: HTMLElement | null = null;
private tableEl: HTMLElement | null = null;
private statusEl: HTMLElement | null = null;
private rangeLabelEl: HTMLElement | null = null;
private reqId = 0;
private lang: "zh" | "en" = "zh";
constructor(app: App, lang: string) {
super(app);
this.lang = lang === "zh" ? "zh" : "en";
}
onOpen(): void {
const { contentEl, modalEl } = this;
contentEl.empty();
modalEl.addClass("pi-agent-usage-modal");
contentEl.addClass("pi-agent-usage-content");
this.render();
}
onClose(): void {
this.contentEl.empty();
this.modalEl.removeClass("pi-agent-usage-modal");
}
private render(): void {
const { contentEl } = this;
contentEl.empty();
const isZh = this.lang === "zh";
const range = buildRange(this.preset, this.customFrom, this.customTo);
// Header
const header = contentEl.createDiv("pi-agent-usage-header");
const titleWrap = header.createDiv("pi-agent-usage-title");
const titleIcon = titleWrap.createSpan();
setIcon(titleIcon, "bar-chart-3");
const titleText = titleWrap.createSpan({ text: isZh ? "Token 用量" : "Token Usage" });
titleText.addClass("pi-agent-title-text-spaced");
this.rangeLabelEl = titleWrap.createSpan({ text: " · " + range.label, cls: "pi-agent-usage-range-label" });
const refreshBtn = header.createEl("button", { cls: "pi-agent-usage-btn-icon", attr: { title: isZh ? "刷新" : "Refresh", "aria-label": "Refresh" } });
setIcon(refreshBtn, "refresh-cw");
refreshBtn.onclick = () => this.scan();
const closeBtn = header.createEl("button", { cls: "pi-agent-usage-btn-icon", attr: { title: isZh ? "关闭" : "Close", "aria-label": "Close" } });
setIcon(closeBtn, "x");
closeBtn.onclick = () => this.close();
// Range selector
const rangeBar = contentEl.createDiv("pi-agent-usage-rangebar");
const presets: { id: UsageRangePreset; label: string }[] = [
{ id: "today", label: isZh ? "今天" : "Today" },
{ id: "yesterday", label: isZh ? "昨天" : "Yesterday" },
{ id: "last7", label: isZh ? "最近 7 天" : "Last 7d" },
{ id: "last30", label: isZh ? "最近 30 天" : "Last 30d" },
{ id: "thisMonth", label: isZh ? "本月" : "This month" },
{ id: "all", label: isZh ? "全部" : "All time" },
{ id: "custom", label: isZh ? "自定义…" : "Custom…" },
];
for (const p of presets) {
const btn = rangeBar.createEl("button", {
text: p.label,
cls: "pi-agent-usage-preset" + (this.preset === p.id ? " is-active" : ""),
});
btn.onclick = () => {
this.preset = p.id;
this.render();
// render() 末尾已调用 scan(),无需重复扫描。
};
}
if (this.preset === "custom") {
const wrap = rangeBar.createDiv("pi-agent-usage-custom-range");
const fromInp = wrap.createEl("input", {
attr: { type: "datetime-local", value: this.customFrom },
});
wrap.createSpan({ text: "→" });
const toInp = wrap.createEl("input", {
attr: { type: "datetime-local", value: this.customTo },
});
fromInp.onchange = () => {
this.customFrom = fromInp.value;
this.scan();
};
toInp.onchange = () => {
this.customTo = toInp.value;
this.scan();
};
}
this.statusEl = rangeBar.createDiv("pi-agent-usage-status");
// Summary
this.summaryEl = contentEl.createDiv("pi-agent-usage-summary");
// Table area
this.tableEl = contentEl.createDiv("pi-agent-usage-table");
this.tableEl.addClass("pi-agent-usage-table-scroll");
// Footer
const footer = contentEl.createDiv("pi-agent-usage-footer");
footer.createSpan({
text: isZh ? "点击列标题排序 · 数据源:~/.pi/agent/sessions" : "Click a column to sort · Data source: ~/.pi/agent/sessions",
});
footer.createSpan({ text: "Esc", cls: "pi-agent-usage-foot-hint" });
this.scan();
}
private scan(): void {
const reqId = ++this.reqId;
this.loading = true;
this.error = null;
this.updateStatus();
window.setTimeout(() => {
if (reqId !== this.reqId) return;
try {
const home = homedir().replace(/\\/g, "/");
const sessionsBaseDir = `${home}/.pi/agent/sessions`;
const range = buildRange(this.preset, this.customFrom, this.customTo);
const result = scanUsageRange(sessionsBaseDir, range.from, range.to);
if (reqId !== this.reqId) return;
this.data = result;
this.loading = false;
this.updateRangeLabel();
this.renderSummary();
this.renderTable();
this.updateStatus();
} catch (err) {
if (reqId !== this.reqId) return;
this.error = (err as Error).message;
this.loading = false;
this.updateStatus();
}
}, 0);
}
private updateRangeLabel(): void {
if (!this.rangeLabelEl) return;
const range = buildRange(this.preset, this.customFrom, this.customTo);
this.rangeLabelEl.setText(" · " + range.label);
}
private updateStatus(): void {
if (!this.statusEl) return;
const isZh = this.lang === "zh";
if (this.error) {
this.statusEl.setText(`${this.error}`);
this.statusEl.addClass("pi-agent-text-error");
return;
}
if (this.loading) {
this.statusEl.setText(isZh ? "扫描中…" : "Scanning…");
this.statusEl.removeClass("pi-agent-text-error");
this.statusEl.addClass("pi-agent-text-muted");
return;
}
if (this.data) {
this.statusEl.setText(
`${isZh ? "已扫描" : "scanned"} ${this.data.sessionCount} ${isZh ? "个会话" : "session" + (this.data.sessionCount === 1 ? "" : "s")}`
);
this.statusEl.removeClass("pi-agent-text-error");
this.statusEl.addClass("pi-agent-text-muted");
}
}
private renderSummary(): void {
if (!this.summaryEl) return;
this.summaryEl.empty();
if (!this.data) return;
const isZh = this.lang === "zh";
const t = this.data.totals;
const hr = computeHitRate(t.input, t.cacheRead);
const cards = [
{ label: isZh ? "总 Token" : "Total tokens", value: fmtNum(t.totalTokens), sub: t.totalTokens.toLocaleString() },
{ label: isZh ? "输入" : "Input", value: fmtNum(t.input), sub: t.input.toLocaleString() },
{ label: isZh ? "输出" : "Output", value: fmtNum(t.output), sub: t.output.toLocaleString() },
{
label: isZh ? "缓存 Σ" : "Cache Σ",
value: fmtNum(t.cacheTotal),
sub: hr === null ? "—" : `${isZh ? "命中率" : "hit"} ${(hr * 100).toFixed(1)}%`,
subColor: hitRateColor(hr),
},
{
label: isZh ? "费用" : "Cost",
value: fmtCost(t.cost),
sub: `${t.messageCount.toLocaleString()} ${isZh ? "条消息" : "msgs"}`,
},
];
for (const c of cards) {
const card = this.summaryEl.createDiv("pi-agent-usage-card");
card.createDiv({ text: c.label, cls: "pi-agent-usage-card-label" });
card.createDiv({ text: c.value, cls: "pi-agent-usage-card-value" });
const sub = card.createDiv({ text: c.sub, cls: "pi-agent-usage-card-sub" });
if (c.subColor) sub.setCssProps({ color: c.subColor });
}
}
private renderTable(): void {
if (!this.tableEl) return;
this.tableEl.empty();
const isZh = this.lang === "zh";
if (this.error) {
this.tableEl.createDiv({
text: `${isZh ? "错误" : "Error"}: ${this.error}`,
cls: "pi-agent-usage-empty",
}).addClass("pi-agent-text-error");
return;
}
if (!this.data) {
this.tableEl.createDiv({
text: isZh ? "加载中…" : "Loading…",
cls: "pi-agent-usage-empty",
});
return;
}
if (this.data.byModel.length === 0) {
this.tableEl.createDiv({
text: isZh ? "此时间范围内没有用量数据" : "No usage data in this range.",
cls: "pi-agent-usage-empty",
});
return;
}
const sorted = [...this.data.byModel].sort((a, b) => {
const aNull = a[this.sortKey] === null || a[this.sortKey] === undefined;
const bNull = b[this.sortKey] === null || b[this.sortKey] === undefined;
if (aNull && bNull) return 0;
if (aNull) return 1;
if (bNull) return -1;
const av = a[this.sortKey] as number;
const bv = b[this.sortKey] as number;
return this.sortDir === "desc" ? bv - av : av - bv;
});
const maxTotal = Math.max(...sorted.map((m) => m.totalTokens));
const totalAll = this.data.totals.totalTokens || 0;
const table = this.tableEl.createEl("table", { cls: "pi-agent-usage-table-el" });
const thead = table.createEl("thead");
const trh = thead.createEl("tr");
const cols: { key: UsageSortKey | "model" | "provider" | "share" | "firstLast"; label: string; align: "left" | "right" }[] = [
{ key: "model", label: isZh ? "模型" : "Model", align: "left" },
{ key: "provider", label: isZh ? "提供方" : "Provider", align: "left" },
{ key: "messageCount", label: isZh ? "消息" : "Msgs", align: "right" },
{ key: "input", label: isZh ? "输入" : "Input", align: "right" },
{ key: "output", label: isZh ? "输出" : "Output", align: "right" },
{ key: "cacheRead", label: isZh ? "缓存读" : "Cache R", align: "right" },
{ key: "cacheWrite", label: isZh ? "缓存写" : "Cache W", align: "right" },
{ key: "totalTokens", label: isZh ? "总计" : "Total", align: "right" },
{ key: "hitRate", label: isZh ? "命中率" : "Hit", align: "right" },
{ key: "cost", label: isZh ? "费用" : "Cost", align: "right" },
{ key: "share", label: isZh ? "占比" : "Share", align: "left" },
];
const sortArrow = (k: string) => this.sortKey === k ? (this.sortDir === "desc" ? " ↓" : " ↑") : "";
for (const c of cols) {
const th = trh.createEl("th", {
text: c.label + (c.key === this.sortKey ? sortArrow(c.key) : ""),
attr: { title: c.label },
});
th.setCssProps({ textAlign: c.align });
if (c.key !== "share") {
th.addClass("is-sortable");
th.onclick = () => {
if (this.sortKey === c.key) {
this.sortDir = this.sortDir === "desc" ? "asc" : "desc";
} else {
this.sortKey = c.key as UsageSortKey;
this.sortDir = c.key === "hitRate" ? "asc" : "desc";
}
this.renderTable();
};
}
}
const tbody = table.createEl("tbody");
for (const m of sorted) {
const tr = tbody.createEl("tr");
// Model
const tdModel = tr.createEl("td");
tdModel.addClass("pi-agent-text-left");
tdModel.createDiv({ cls: "pi-agent-usage-model-name", text: m.model });
tdModel.createDiv({ cls: "pi-agent-usage-model-time", text: `${fmtDateCompact(m.firstUsed ? new Date(m.firstUsed).toISOString() : null)}${fmtDateCompact(m.lastUsed ? new Date(m.lastUsed).toISOString() : null)}` });
// Provider
const tdProv = tr.createEl("td", { text: m.provider });
tdProv.addClass("pi-agent-text-left");
tdProv.addClass("pi-agent-text-muted");
// Numeric cells
const cells: Array<[string, "right" | "left", string?]> = [
[m.messageCount.toLocaleString(), "right"],
[fmtNum(m.input), "right"],
[fmtNum(m.output), "right"],
[fmtNum(m.cacheRead), "right"],
[fmtNum(m.cacheWrite), "right"],
[fmtNum(m.totalTokens), "right"],
[hitRateLabel(m.hitRate), "right", hitRateColor(m.hitRate)],
[fmtCost(m.cost), "right"],
];
for (const [val, align, color] of cells) {
const td = tr.createEl("td", { text: val });
td.setCssProps({ textAlign: align });
td.addClass("pi-agent-tabular-nums");
if (color) td.setCssProps({ color });
}
// Share bar
const tdShare = tr.createEl("td");
tdShare.addClass("pi-agent-text-left");
tdShare.addClass("pi-agent-share-cell");
const pct = totalAll > 0 ? (m.totalTokens / totalAll) * 100 : 0;
const barW = maxTotal > 0 ? (m.totalTokens / maxTotal) * 100 : 0;
const barWrap = tdShare.createDiv({ cls: "pi-agent-usage-bar" });
const bar = barWrap.createDiv({ cls: "pi-agent-usage-bar-fill" });
bar.setCssProps({ width: `${barW}%` });
tdShare.createSpan({ text: `${pct.toFixed(1)}%`, cls: "pi-agent-usage-pct" });
}
}
}