Improve agent terminal reuse and diff refresh

This commit is contained in:
megan-blue 2026-07-07 16:05:12 +08:00
parent 6aabbc1116
commit 1d8ea77fa4
8 changed files with 502 additions and 81 deletions

174
main.js

File diff suppressed because one or more lines are too long

View file

@ -2,9 +2,11 @@ import { App, Notice } from "obsidian";
import { isMobile } from "../utils/platform";
import { copyToClipboard } from "../export/Exporters";
import { t } from "../i18n/i18n";
import { execSync, fs, os, nodePath } from "../utils/nodeApi";
import { execFileSync, fs, os, nodePath, nodeCrypto } from "../utils/nodeApi";
import { shellEscape } from "../utils/shellescape";
export type TerminalApp = "Terminal" | "iTerm2";
const AGENT_TERMINAL_TITLE_PREFIX = "Promptuary Agent";
export interface LaunchOptions {
command: string;
@ -49,23 +51,21 @@ export function launchInTerminal(opts: LaunchOptions): void {
function launchMacOS(opts: LaunchOptions): void {
// Write the command to a temp shell script to avoid all AppleScript quoting issues.
// The script path is safe ASCII no special chars to escape.
// The script path is safe ASCII - no special chars to escape.
const tmpScript = nodePath.join(os.tmpdir(), `prm-agent-${Date.now()}.sh`);
fs.writeFileSync(tmpScript, `#!/bin/bash\n${opts.command}\n`, { mode: 0o755 });
// Schedule cleanup after 60 s (well after execution starts)
window.setTimeout(() => { try { fs.unlinkSync(tmpScript); } catch { /* ignore */ } }, 60_000);
let appleScript: string;
if (opts.terminalApp === "iTerm2") {
appleScript = `tell application "iTerm"\nactivate\ncreate window with default profile command "${tmpScript}"\nend tell`;
} else {
appleScript = `tell application "Terminal"\ndo script "${tmpScript}"\nactivate\nend tell`;
}
const terminalCommand = buildTerminalSessionCommand(tmpScript, opts.vaultPath);
const appleScript = opts.terminalApp === "iTerm2"
? buildITermAppleScript(terminalCommand, opts.vaultPath)
: buildTerminalAppleScript(terminalCommand, opts.vaultPath);
try {
execSync(`osascript -e '${appleScript}'`, {
execFileSync("osascript", [], {
input: appleScript,
encoding: "utf-8",
timeout: 10_000,
stdio: "pipe",
@ -79,15 +79,184 @@ function launchMacOS(opts: LaunchOptions): void {
}
}
function buildTerminalAppleScript(command: string, vaultPath: string): string {
const commandText = escapeAppleScriptString(command);
const sessionTitle = escapeAppleScriptString(buildAgentTerminalTitle(vaultPath));
const targetCwd = escapeAppleScriptString(normalizeVaultPath(vaultPath));
return `
${buildCwdLookupAppleScriptHandler()}
tell application "Terminal"
activate
set targetTab to missing value
set targetCwd to "${targetCwd}"
repeat with terminalWindow in windows
repeat with terminalTab in tabs of terminalWindow
if (custom title of terminalTab is "${sessionTitle}") and (busy of terminalTab is false) then
set targetTab to terminalTab
set index of terminalWindow to 1
set selected of terminalTab to true
exit repeat
end if
end repeat
if targetTab is not missing value then exit repeat
end repeat
if targetTab is missing value then
repeat with terminalWindow in windows
repeat with terminalTab in tabs of terminalWindow
if busy of terminalTab is false then
set tabCwd to my promptuaryCwdForTty(tty of terminalTab)
if tabCwd is targetCwd then
set targetTab to terminalTab
set index of terminalWindow to 1
set selected of terminalTab to true
exit repeat
end if
end if
end repeat
if targetTab is not missing value then exit repeat
end repeat
end if
if targetTab is missing value then
set targetTab to do script "${commandText}"
set custom title of targetTab to "${sessionTitle}"
else
do script "${commandText}" in targetTab
set custom title of targetTab to "${sessionTitle}"
end if
end tell
`.trim();
}
function buildITermAppleScript(command: string, vaultPath: string): string {
const commandText = escapeAppleScriptString(command);
const sessionTitle = escapeAppleScriptString(buildAgentTerminalTitle(vaultPath));
const targetCwd = escapeAppleScriptString(normalizeVaultPath(vaultPath));
return `
${buildCwdLookupAppleScriptHandler()}
tell application "iTerm"
activate
set targetSession to missing value
set targetCwd to "${targetCwd}"
repeat with terminalWindow in windows
repeat with terminalTab in tabs of terminalWindow
repeat with terminalSession in sessions of terminalTab
if (name of terminalSession is "${sessionTitle}") and (is processing of terminalSession is false) then
set targetSession to terminalSession
exit repeat
end if
end repeat
if targetSession is not missing value then exit repeat
end repeat
if targetSession is not missing value then exit repeat
end repeat
if targetSession is missing value then
repeat with terminalWindow in windows
repeat with terminalTab in tabs of terminalWindow
repeat with terminalSession in sessions of terminalTab
if (is processing of terminalSession is false) then
set sessionCwd to my promptuaryCwdForTty(tty of terminalSession)
if sessionCwd is targetCwd then
set targetSession to terminalSession
exit repeat
end if
end if
end repeat
if targetSession is not missing value then exit repeat
end repeat
if targetSession is not missing value then exit repeat
end repeat
end if
if targetSession is missing value then
if (count of windows) is 0 then
set targetWindow to (create window with default profile)
else
set targetWindow to current window
tell targetWindow
create tab with default profile
end tell
end if
set targetSession to current session of targetWindow
end if
tell targetSession
set name to "${sessionTitle}"
write text "${commandText}"
end tell
end tell
`.trim();
}
function buildTerminalSessionCommand(tmpScript: string, vaultPath: string): string {
return `cd ${shellEscape(normalizeVaultPath(vaultPath))} && ${shellEscape(tmpScript)}`;
}
function escapeAppleScriptString(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
}
function buildCwdLookupAppleScriptHandler(): string {
return `
on promptuaryCwdForTty(tabTty)
if tabTty is missing value then return ""
set ttyText to tabTty as text
if ttyText is "" then return ""
if ttyText starts with "/dev/" then set ttyText to text 6 thru -1 of ttyText
set lookupCmd to "tty=" & quoted form of ttyText & "; pid=$(/bin/ps -t \\"$tty\\" -o pid= 2>/dev/null | /usr/bin/head -n 1 | /usr/bin/tr -d ' '); if [ -n \\"$pid\\" ]; then /usr/sbin/lsof -a -p \\"$pid\\" -d cwd -Fn 2>/dev/null | /usr/bin/sed -n 's/^n//p' | /usr/bin/head -n 1; fi"
try
return do shell script lookupCmd
on error
return ""
end try
end promptuaryCwdForTty
`.trim();
}
function buildAgentTerminalTitle(vaultPath: string): string {
const normalizedPath = normalizeVaultPath(vaultPath);
const vaultName = sanitizeTitlePart(nodePath.basename(normalizedPath) || "Vault");
const pathHash = nodeCrypto.createHash("sha256")
.update(normalizedPath)
.digest("hex")
.slice(0, 12);
return `${AGENT_TERMINAL_TITLE_PREFIX} - ${vaultName} - ${pathHash}`;
}
function normalizeVaultPath(vaultPath: string): string {
let resolved = nodePath.resolve(vaultPath);
try {
resolved = fs.realpathSync.native(resolved);
} catch {
// Keep the resolved path when the directory cannot be realpathed.
}
const parsed = nodePath.parse(resolved);
return resolved === parsed.root ? resolved : resolved.replace(/[\\/]+$/, "");
}
function sanitizeTitlePart(value: string): string {
return value.replace(/\s+/g, " ").trim() || "Vault";
}
// ---------- file change monitor ----------
export type ChangeStatus = "idle" | "running" | "detected" | "timeout";
export class FileChangeMonitor {
private status: ChangeStatus = "idle";
private timer: number | null = null;
private timeoutTimer: number | null = null;
private settleTimer: number | null = null;
private resolve: ((detected: boolean) => void) | null = null;
private watchedAbsPath: string | null = null;
private unwatch: (() => void) | null = null;
/**
* Start monitoring a file for changes made by an external process.
@ -96,12 +265,13 @@ export class FileChangeMonitor {
* because vault events are only fired for writes Obsidian itself initiates
* external CLI Agents write directly to disk and vault events are unreliable.
*
* Resolves `true` when mtime changes, `false` on timeout.
* Resolves `true` after a detected change stays quiet briefly, `false` on timeout.
*/
startMonitor(
app: App,
filePath: string,
timeoutMs = 5 * 60 * 1000,
settleMs = 1500,
): Promise<boolean> {
this.status = "running";
@ -112,29 +282,35 @@ export class FileChangeMonitor {
return new Promise<boolean>((resolve) => {
this.resolve = resolve;
// Timeout guard
this.timer = window.setTimeout(() => {
const finish = (detected: boolean, status: ChangeStatus) => {
if (this.status !== "running") return;
this.cleanup();
this.status = "timeout";
resolve(false);
this.status = status;
this.resolve = null;
resolve(detected);
};
this.timeoutTimer = window.setTimeout(() => {
finish(false, "timeout");
}, timeoutMs);
// Poll every 1 s using fs.watchFile
const listener = (curr: import("fs").Stats, prev: import("fs").Stats) => {
if (curr.mtimeMs !== prev.mtimeMs && this.status === "running") {
this.cleanup();
this.status = "detected";
resolve(true);
}
if (this.status !== "running") return;
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return;
if (this.settleTimer) window.clearTimeout(this.settleTimer);
this.settleTimer = window.setTimeout(() => {
finish(true, "detected");
}, settleMs);
};
fs.watchFile(absPath, { persistent: false, interval: 1000 }, listener);
// Override cleanup to also stop the watcher
const origCleanup: () => void = this.cleanup;
this.cleanup = () => {
origCleanup();
try { fs.unwatchFile(absPath, listener); } catch { /* ignore */ }
this.unwatch = () => {
try {
fs.unwatchFile(absPath, listener);
} catch {
// ignore
}
};
});
}
@ -142,9 +318,11 @@ export class FileChangeMonitor {
/** Cancel an in-progress monitor */
cancel(): void {
if (this.status === "running") {
const resolve = this.resolve;
this.cleanup();
this.status = "idle";
this.resolve?.(false);
this.resolve = null;
resolve?.(false);
}
}
@ -153,10 +331,17 @@ export class FileChangeMonitor {
}
private cleanup = (): void => {
if (this.timer) {
window.clearTimeout(this.timer);
this.timer = null;
if (this.timeoutTimer) {
window.clearTimeout(this.timeoutTimer);
this.timeoutTimer = null;
}
if (this.settleTimer) {
window.clearTimeout(this.settleTimer);
this.settleTimer = null;
}
if (this.unwatch) {
this.unwatch();
this.unwatch = null;
}
};
}

View file

@ -1,4 +1,4 @@
import { App, Modal, setIcon } from "obsidian";
import { App, Modal, Notice, setIcon } from "obsidian";
import {
DiffBlock,
computeDiff,
@ -12,6 +12,10 @@ export interface DiffModalResult {
mergedText?: string;
}
interface DiffModalOptions {
reloadModifiedText?: () => Promise<string>;
}
interface Hunk {
header: string;
blocks: DiffBlock[];
@ -57,8 +61,8 @@ function groupIntoHunks(blocks: DiffBlock[], ctxLines = 2): Hunk[] {
}
export class DiffModal extends Modal {
private blocks: DiffBlock[];
private hunks: Hunk[];
private blocks: DiffBlock[] = [];
private hunks: Hunk[] = [];
/** true = accepted, false = rejected, undefined = pending */
private hunkDecisions: Map<number, boolean> = new Map();
private acceptMap: Map<number, boolean> = new Map();
@ -70,11 +74,18 @@ export class DiffModal extends Modal {
private originalText: string,
private modifiedText: string,
private fileName: string,
private options: DiffModalOptions = {},
) {
super(app);
this.blocks = computeDiff(originalText, modifiedText);
this.hunks = groupIntoHunks(this.blocks);
// Default: all changes accepted
this.resetDiff(modifiedText);
}
private resetDiff(modifiedText: string): void {
this.modifiedText = modifiedText;
this.blocks = computeDiff(this.originalText, modifiedText);
this.hunks = groupIntoHunks(this.blocks).filter((hunk) => hunk.changeIndices.length > 0);
this.acceptMap.clear();
this.hunkDecisions.clear();
for (const b of this.blocks) {
if (b.type !== "unchanged") this.acceptMap.set(b.index, true);
}
@ -110,7 +121,19 @@ export class DiffModal extends Modal {
const hunkBadge = headerRight.createSpan({ cls: "prm-dm-hunk-badge" });
hunkBadge.setText(`${this.hunks.length} ${t("diff.hunks")}`);
if (this.options.reloadModifiedText) {
const refreshBtn = headerRight.createEl("button", { cls: "prm-dm-refresh" });
refreshBtn.title = t("diff.btn.refresh");
refreshBtn.setAttribute("aria-label", t("diff.btn.refresh"));
setIcon(refreshBtn, "refresh-cw");
refreshBtn.createSpan({ text: t("diff.btn.refresh") });
refreshBtn.onclick = () => {
void this.refreshDiff(refreshBtn);
};
}
const closeBtn = headerRight.createEl("button", { cls: "prm-dm-close" });
closeBtn.title = t("common.close");
setIcon(closeBtn, "x");
closeBtn.onclick = () => {
this.result = { action: "reject" };
@ -150,15 +173,14 @@ export class DiffModal extends Modal {
setIcon(applyBtn, "check");
applyBtn.createSpan({ text: t("diff.btn.applySelected") });
applyBtn.onclick = () => {
this.result = this.buildResultFromAcceptMap();
this.close();
void this.applySelected();
};
}
onClose(): void {
if (this.resolve) {
if (!this.result) {
this.result = this.buildResultFromAcceptMap();
this.result = { action: "reject" };
}
this.resolve(this.result);
this.resolve = null;
@ -168,6 +190,9 @@ export class DiffModal extends Modal {
private buildResultFromAcceptMap(): DiffModalResult {
// Build the result from current hunk-level decisions.
// Default map value is true, so untouched hunks are accepted unless rejected.
if (this.acceptMap.size === 0) {
return { action: "reject" };
}
const decisions = [...this.acceptMap.values()];
const allAccepted = decisions.every(v => v);
const allRejected = decisions.every(v => !v);
@ -175,12 +200,52 @@ export class DiffModal extends Modal {
return { action: "reject" };
}
if (allAccepted) {
return { action: "accept-all" };
return { action: "accept-all", mergedText: this.modifiedText };
}
const merged = applyPartialDiff(this.originalText, this.blocks, this.acceptMap);
return { action: "accept-partial", mergedText: merged };
}
private async refreshDiff(refreshBtn: HTMLButtonElement): Promise<void> {
if (!this.options.reloadModifiedText) return;
refreshBtn.disabled = true;
refreshBtn.addClass("is-loading");
try {
const modifiedText = await this.options.reloadModifiedText();
this.result = null;
this.resetDiff(modifiedText);
this.onOpen();
new Notice(t("diff.notice.refreshed"));
} catch {
refreshBtn.disabled = false;
refreshBtn.removeClass("is-loading");
new Notice(t("diff.notice.refreshFailed"));
}
}
private async applySelected(): Promise<void> {
if (this.options.reloadModifiedText) {
try {
const latestModifiedText = await this.options.reloadModifiedText();
if (latestModifiedText !== this.modifiedText) {
this.result = null;
this.resetDiff(latestModifiedText);
this.onOpen();
new Notice(t("diff.notice.changedBeforeApply"));
return;
}
} catch {
new Notice(t("diff.notice.refreshFailed"));
return;
}
}
this.result = this.buildResultFromAcceptMap();
this.close();
}
private renderHunk(parent: HTMLElement, hunk: Hunk, idx: number): void {
const wrap = parent.createDiv({ cls: "prm-dm-hunk" });

View file

@ -305,6 +305,10 @@ const en: Record<string, string> = {
"diff.btn.applySelected": "Apply selected",
"diff.btn.reject": "Reject",
"diff.btn.accept": "Accept",
"diff.btn.refresh": "Refresh",
"diff.notice.refreshed": "Diff refreshed",
"diff.notice.refreshFailed": "Failed to refresh diff",
"diff.notice.changedBeforeApply": "File changed again. Diff refreshed, please review again.",
"diff.hunks": "changes",
// ==================== api ====================

View file

@ -322,6 +322,10 @@ const zhCN: Record<string, string> = {
"diff.btn.applySelected": "应用选中",
"diff.btn.reject": "拒绝",
"diff.btn.accept": "接受",
"diff.btn.refresh": "刷新",
"diff.notice.refreshed": "已刷新 Diff",
"diff.notice.refreshFailed": "刷新 Diff 失败",
"diff.notice.changedBeforeApply": "文件有新变化,已刷新 Diff请重新确认",
"diff.hunks": "个变更",
// ==================== api ====================

View file

@ -396,8 +396,9 @@ export default class PromptuaryPlugin extends Plugin {
switch (diffResult.action) {
case "accept-all": {
await this.app.vault.modify(file, modifiedText);
await this.reanchorAndConfirm(targetPath, originalText, modifiedText);
const finalText = diffResult.mergedText ?? modifiedText;
await this.app.vault.modify(file, finalText);
await this.reanchorAndConfirm(targetPath, originalText, finalText);
new Notice(t("main.notice.allAccepted"));
break;
}
@ -665,11 +666,14 @@ export default class PromptuaryPlugin extends Plugin {
original,
modified,
fileName,
{
reloadModifiedText: () => this.app.vault.read(file),
},
).openForResult();
switch (result.action) {
case "accept-all": {
const finalText = modified;
const finalText = result.mergedText ?? modified;
await this.reanchorAndConfirm(filePath, original, finalText);
this.originalTextBeforeAgent = null;
new Notice(t("main.notice.allAccepted"));

View file

@ -14,7 +14,8 @@
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-require-imports -- window.require is the only way to access Node.js builtins in Obsidian's renderer process
const nodeRequire = (window as unknown as { require?: NodeRequire }).require ?? require;
export const { execSync, exec } = nodeRequire("child_process") as typeof import("child_process");
export const { execSync, exec, execFileSync } = nodeRequire("child_process") as typeof import("child_process");
export const fs = nodeRequire("fs") as typeof import("fs");
export const os = nodeRequire("os") as typeof import("os");
export const nodePath = nodeRequire("path") as typeof import("path");
export const nodeCrypto = nodeRequire("crypto") as typeof import("crypto");

View file

@ -1540,6 +1540,40 @@
border: 1px solid var(--background-modifier-border);
color: var(--text-muted);
}
.prm-dm-refresh {
display: inline-flex; align-items: center; gap: 4px;
height: 28px;
padding: 0 8px;
border-radius: 6px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
color: var(--text-muted);
cursor: pointer;
font-size: 10px;
font-family: var(--font-monospace);
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.prm-dm-refresh:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.prm-dm-refresh:disabled {
cursor: default;
opacity: 0.75;
}
.prm-dm-refresh svg {
width: 14px;
height: 14px;
display: block;
flex-shrink: 0;
}
.prm-dm-refresh.is-loading svg {
animation: prm-dm-spin 0.8s linear infinite;
}
@keyframes prm-dm-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.prm-dm-close {
width: 28px; height: 28px;
border-radius: 6px;
@ -1572,6 +1606,7 @@
border-radius: 8px; overflow: hidden;
background: var(--background-primary);
transition: opacity 0.2s, transform 0.2s;
flex: 0 0 auto;
}
.prm-dm-hunk.prm-dm-hunk-decided {
opacity: 0;
@ -1580,9 +1615,11 @@
}
.prm-dm-hunk-header {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
padding: 6px 12px;
background: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
flex-shrink: 0;
}
.prm-dm-hunk-header-left { display: flex; align-items: center; gap: 8px; }
.prm-dm-hunk-pos {
@ -1642,7 +1679,8 @@
.prm-dm-lines {
font-family: var(--font-monospace);
font-size: 11px; line-height: 1.7;
max-height: min(52vh, 520px);
max-height: min(46vh, 460px);
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
@ -1654,6 +1692,7 @@
.prm-dm-line {
display: flex; align-items: baseline;
padding: 0 12px;
min-height: 19px;
}
.prm-dm-line-unchanged {
color: var(--text-normal);
@ -1694,6 +1733,17 @@
}
.prm-dm-text { white-space: pre-wrap; word-break: break-all; flex: 1; }
@media (max-width: 640px) {
.prm-dm-refresh span { display: none; }
.prm-dm-hunk-header {
align-items: flex-start;
flex-direction: column;
}
.prm-dm-hunk-actions {
align-self: flex-end;
}
}
/* Footer */
.prm-dm-footer {
display: flex; align-items: center; justify-content: space-between;