Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 104 additions & 26 deletions components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o
const { t } = useI18n();
const [hovered, setHovered] = useState(false);
const [copied, setCopied] = useState(false);
// 斜杠命令消息:默认折叠为命令 chip,点击展开 SDK 展开后的完整文本
const [expanded, setExpanded] = useState(false);
const originalText = message.originalText;
// 拆分命令名与参数:/skill:web-access 验证参数 → 命令名 + 参数(无空格则为纯命令)
const rawCommand = originalText ?? "";
const spaceIndex = rawCommand.indexOf(" ");
const commandName = spaceIndex === -1 ? rawCommand : rawCommand.slice(0, spaceIndex);
const commandArgs = spaceIndex === -1 ? "" : rawCommand.slice(spaceIndex + 1).trim();

const content =
typeof message.content === "string"
Expand All @@ -167,10 +175,39 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o

const time = formatTime(message.timestamp);
const canFork = !!entryId && !!onFork;
// 复制/编辑使用命令原文(界面所见即所得),展开全文仅用于展示
const copyTarget = originalText ?? content;

// 图片渲染(originalText 折叠分支与原分支共用,避免斜杠命令+图片时图片消失)
const imageBlocksNode = imageBlocks.length > 0 && (
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: content ? 8 : 0 }}>
{imageBlocks.map((img, i) => {
// lib/types.ts ImageContent uses {source:{type,data,media_type,url}}
// pi-ai on-disk format uses flat {data, mimeType} — handle both
const flat = img as unknown as { data?: string; mimeType?: string };
const src = img.source
? img.source.type === "base64"
? `data:${img.source.media_type};base64,${img.source.data}`
: img.source.url ?? ""
: flat.data
? `data:${flat.mimeType};base64,${flat.data}`
: "";
return (
// eslint-disable-next-line @next/next/no-img-element
<img
key={i}
src={src}
alt=""
style={{ maxWidth: 240, maxHeight: 240, borderRadius: 6, objectFit: "contain", display: "block", border: "1px solid rgba(59,130,246,0.15)" }}
/>
);
})}
</div>
);
const canNavigate = !!prevAssistantEntryId && !!onNavigate;

const copyContent = () => {
copyText(content).then(() => {
copyText(copyTarget).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
Expand All @@ -197,32 +234,73 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o
wordBreak: "break-word",
}}
>
{imageBlocks.length > 0 && (
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: content ? 8 : 0 }}>
{imageBlocks.map((img, i) => {
// lib/types.ts ImageContent uses {source:{type,data,media_type,url}}
// pi-ai on-disk format uses flat {data, mimeType} — handle both
const flat = img as unknown as { data?: string; mimeType?: string };
const src = img.source
? img.source.type === "base64"
? `data:${img.source.media_type};base64,${img.source.data}`
: img.source.url ?? ""
: flat.data
? `data:${flat.mimeType};base64,${flat.data}`
: "";
return (
// eslint-disable-next-line @next/next/no-img-element
<img
key={i}
src={src}
alt=""
style={{ maxWidth: 240, maxHeight: 240, borderRadius: 6, objectFit: "contain", display: "block", border: "1px solid rgba(59,130,246,0.15)" }}
/>
);
})}
{originalText ? (
// 斜杠命令消息:命令名紧凑展示(accent 色,点击展开/折叠全文),
// 用户输入的参数部分原文完整展示,不折叠;无 hover 背景反馈
<div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 0 }}>
{imageBlocksNode}
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, flexWrap: "wrap" }}>
<button
onClick={() => setExpanded((prev) => !prev)}
title={expanded ? t("i18n.collapse") : t("i18n.expand")}
aria-expanded={expanded}
style={{
display: "flex",
alignItems: "center",
gap: 6,
flexShrink: 0,
padding: 0,
background: "none",
border: "none",
cursor: "pointer",
color: "var(--accent)",
fontFamily: "var(--font-mono)",
fontSize: 13,
textAlign: "left",
}}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{commandName}
</span>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ flexShrink: 0, opacity: 0.75, transform: expanded ? "rotate(180deg)" : "none", transition: "transform 0.15s" }}
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{commandArgs && (
<span style={{
color: "var(--text)",
fontSize: 14,
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
minWidth: 0,
flex: 1,
}}>
{commandArgs}
</span>
)}
</div>
{expanded && (
<MarkdownBody className="markdown-user-message" cwd={cwd} onOpenFile={onOpenFile}>{content}</MarkdownBody>
)}
</div>
)}
) : (
<>
{imageBlocksNode}
{content && <MarkdownBody className="markdown-user-message" cwd={cwd} onOpenFile={onOpenFile}>{content}</MarkdownBody>}
</>
)}
</div>

</div>
Expand Down Expand Up @@ -278,7 +356,7 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o
}}>
{canNavigate && (
<button
onClick={() => { onNavigate!(prevAssistantEntryId!); onEditContent?.(content); }}
onClick={() => { onNavigate!(prevAssistantEntryId!); onEditContent?.(copyTarget); }}
title={t("i18n.editFromHereTitle")}
style={{
display: "flex", alignItems: "center", gap: 4,
Expand Down
27 changes: 27 additions & 0 deletions lib/rpc-manager.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,30 @@ test("reloading a session invalidates the models cache", async () => {
assert.match(reloadSource, /await this\.inner\.reload\(\)/);
assert.match(reloadSource, /this\.applyForcedEmptySystemPrompt\(\);\s*invalidateModelsCache\(\)/);
});

test("slash command injection rebuilds the message object instead of mutating SDK-shared state", async () => {
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
const startSource = source.slice(source.indexOf("start(): void"));

// SDK 的 _emit 同步分发后会用同一 message 对象落盘/进入模型上下文,
// 注入必须构造新对象而不是改写 event.message
assert.match(startSource, /this\.emit\(\{ \.\.\.event, message: \{ \.\.\.\(event\.message as Record<string, unknown>\), originalText \} \}\)/);
assert.doesNotMatch(startSource, /\(event\.message as Record<string, unknown>\)\.originalText/);
});

test("slash command injection consumes one FIFO entry per user message_end", async () => {
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
const startSource = source.slice(source.indexOf("start(): void"));

// 无论特征识别是否命中都要消费一条,避免残留记录错注入下一条消息
assert.match(startSource, /const fromQueue = this\.slashOriginals\.consumeFor\(text\);/);
assert.match(startSource, /const originalText = resolveSlashDisplayText\(text\) \?\? fromQueue;/);
});

test("slash command tracker is cleared when prompt fails or is rejected", async () => {
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");

// isBashRunning 拒绝、prompt catch、steer/follow_up catch 三处清理
const clearCount = source.match(/slashOriginals\.clear\(\)/g)?.length ?? 0;
assert.ok(clearCount >= 3, `expected >= 3 clear() calls, got ${clearCount}`);
});
60 changes: 58 additions & 2 deletions lib/rpc-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { validateAgentImages } from "./image-attachments";
import { invalidateModelsCache } from "./models-cache";
import { resolveVisibleModels, selectInitialModelScope } from "./model-scope";
import { cacheSessionPath, invalidateSessionListCache } from "./session-reader";
import { parseSlashCommandName, resolveSlashDisplayText, SlashOriginalTracker, userMessagePlainText } from "./slash-display";
import { getProjectTrustStatus, projectTrustReloadOptions } from "./project-trust";
import { persistExplicitStartupPreferences } from "./startup-preferences";
import type { SlashCommandInfo } from "@earendil-works/pi-coding-agent";
Expand Down Expand Up @@ -151,6 +152,8 @@ export class AgentSessionWrapper {
private onDestroyCallback: (() => void) | null = null;
private shutdownPromise: Promise<void> | null = null;
private _alive = true;
/** FIFO: 已发送且确定会被 SDK 展开的斜杠命令原文,user 消息回传时消费注入 */
private slashOriginals = new SlashOriginalTracker();

constructor(public readonly inner: AgentSessionLike) {}

Expand Down Expand Up @@ -179,6 +182,23 @@ export class AgentSessionWrapper {
if (event.type === "agent_end") {
invalidateSessionListCache();
}
// 斜杠命令(/skill:name /模板名)由 SDK 展开后回传:在 message_end 的 user
// 消息上注入原始命令,供界面紧凑展示。
// 注意:SDK 的 _emit 同步分发后会用同一 message 对象 appendMessage 落盘并进入
// 模型上下文,因此绝不能改写原对象 —— 用展开运算符构造新对象注入展示层字段。
// 仅在 message_end 处理 —— message_start/update 的 user 消息被前端忽略。
if (event.type === "message_end" && (event.message as { role?: string } | undefined)?.role === "user") {
const message = event.message as { content?: string | Array<{ type: string; text?: string }> };
const text = userMessagePlainText(message.content ?? "");
// 每条 user message_end 固定消费一条 FIFO(无论特征识别是否命中),
// 避免记录残留导致下一条消息错注入;特征识别结果优先(skill 展开文本可自证)
const fromQueue = this.slashOriginals.consumeFor(text);
const originalText = resolveSlashDisplayText(text) ?? fromQueue;
if (originalText && originalText !== text) {
this.emit({ ...event, message: { ...(event.message as Record<string, unknown>), originalText } });
return;
}
}
if (IDLE_RESET_EVENT_TYPES.has(event.type)) this.resetIdleTimer();
this.emit(event);
if (RUNNING_STATE_EVENT_TYPES.has(event.type)) notifyRunningChange();
Expand Down Expand Up @@ -280,6 +300,22 @@ export class AgentSessionWrapper {
}
}

/**
* 记录确定会被 SDK 展开的斜杠命令原文(命中 prompt 模板或 skill)。
* 只记录这两类:扩展命令由 SDK 立即执行、不产生 user 消息,记录会造成 FIFO 残留。
*/
private trackSlashOriginal(message: string): void {
if (typeof message !== "string") return;
const name = parseSlashCommandName(message);
if (!name) return;
const isPromptTemplate = this.inner.promptTemplates.some((template) => template.name === name);
const isSkill = name.startsWith("skill:")
&& this.inner.resourceLoader.getSkills().skills.some((skill) => `skill:${skill.name}` === name);
if (isPromptTemplate || isSkill) {
this.slashOriginals.push(message.trim());
}
}

private applyForcedEmptySystemPrompt(): void {
if (this.forceEmptySystemPrompt && this.inner.agent.state) {
this.inner.agent.state.systemPrompt = "";
Expand Down Expand Up @@ -344,11 +380,16 @@ export class AgentSessionWrapper {
if (type === "prompt" || type === "steer" || type === "follow_up") {
const imageError = validateAgentImages(command.images);
if (imageError) throw new Error(imageError);
// 记录确定会被 SDK 展开的斜杠命令(命中 prompt 模板或 skill)原文,
// 供 message_end 回传时注入展示。扩展命令不产生 user 消息,不记录。
this.trackSlashOriginal(command.message as string);
}

switch (type) {
case "prompt": {
if (this.inner.isBashRunning) {
// prompt 未发出,刚 push 的斜杠命令记录不会再有 message_end 消费,立即清空
this.slashOriginals.clear();
throw new Error("Cannot send a prompt while a shell command is running");
}
// Fire and forget — events come via subscribe
Expand All @@ -367,6 +408,9 @@ export class AgentSessionWrapper {
notifyRunningChange();
}).catch((error) => {
this.promptRunning = false;
// prompt 发送失败(无 model / auth 校验失败等)不会产生 message_end,
// 清理队列避免残留记录错注入后续消息
this.slashOriginals.clear();
this.resetIdleTimer();
invalidateSessionListCache();
this.emit({
Expand Down Expand Up @@ -526,13 +570,25 @@ export class AgentSessionWrapper {

case "steer": {
const steerImages = command.images as Array<{ type: "image"; data: string; mimeType: string }> | undefined;
await this.inner.steer(command.message as string, steerImages?.length ? steerImages : undefined);
try {
await this.inner.steer(command.message as string, steerImages?.length ? steerImages : undefined);
} catch (error) {
// steer 入队失败不会产生 message_end,清理本次记录避免残留
this.slashOriginals.clear();
throw error;
}
return null;
}

case "follow_up": {
const followImages = command.images as Array<{ type: "image"; data: string; mimeType: string }> | undefined;
await this.inner.followUp(command.message as string, followImages?.length ? followImages : undefined);
try {
await this.inner.followUp(command.message as string, followImages?.length ? followImages : undefined);
} catch (error) {
// followUp 入队失败不会产生 message_end,清理本次记录避免残留
this.slashOriginals.clear();
throw error;
}
return null;
}

Expand Down
17 changes: 16 additions & 1 deletion lib/session-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import {
} from "@earendil-works/pi-coding-agent";
import { closeSync, openSync, readSync } from "fs";
import { normalize as normalizePath } from "path";
import type { AgentMessage, SessionEntry, SessionHeader, SessionInfo, SessionContext } from "./types";
import type { AgentMessage, SessionEntry, SessionHeader, SessionInfo, SessionContext, UserMessage } from "./types";
import type { SessionEntry as PiSessionEntry, SessionInfo as PiSessionInfo } from "@earendil-works/pi-coding-agent";
import { normalizeToolCalls } from "./normalize";
import { resolveSlashDisplayText, userMessagePlainText } from "./slash-display";
import { sessionPathKey } from "./session-path";
import { resolveProject, type ProjectInfo } from "./worktree";

Expand Down Expand Up @@ -226,6 +227,20 @@ export function buildSessionContext(
const localEntry = entry as unknown as SessionEntry;
const m = entryToUiMessage(localEntry, options);
if (m) {
// 斜杠命令展开消息(/skill:name /模板名):还原紧凑命令形式供界面展示。
// prompt 模板展开文本无特征标记,无法还原,保持全文(已知局限)。
// 注意用展开运算符新建消息对象注入 originalText,不改写 SDK 返回的原始对象
//(该对象可能被 SDK 后续持久化复用,直接赋值会污染存储)。
if (m.role === "user") {
const userMessage = m as UserMessage;
const text = userMessagePlainText(userMessage.content);
const originalText = resolveSlashDisplayText(text);
if (originalText && originalText !== text) {
messages.push({ ...userMessage, originalText });
entryIds.push(localEntry.id);
continue;
}
}
messages.push(m);
entryIds.push(localEntry.id);
}
Expand Down
Loading