mirror of
https://github.com/gorazenxu/pimate.git
synced 2026-07-22 07:32:35 +00:00
- 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
6904 lines
238 KiB
TypeScript
6904 lines
238 KiB
TypeScript
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" });
|
||
}
|
||
}
|
||
}
|