mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(desktop): show structured skill invocations
This commit is contained in:
1 parent
fc30cb66d3
commit
19d580ccda
12 files changed
+488
-69
No files matched your search
+11
-2
@@ -4422,8 +4422,17 @@ func historyMessagesWithPlannerDisplaysAndLookups(
|
||||
if m.Role == provider.RoleAssistant && len(m.MemoryCitations) > 0 {
|
||||
hm.MemoryCitations = append([]provider.MemoryCitation(nil), m.MemoryCitations...)
|
||||
}
|
||||
if m.Role == provider.RoleUser && content != m.Content && !agent.ContainsMemoryCompilerExecution(m.Content) {
|
||||
hm.SubmitText = m.Content
|
||||
if m.Role == provider.RoleUser && content != m.Content {
|
||||
if agent.ContainsMemoryCompilerExecution(m.Content) {
|
||||
// Never expose the compiler contract itself. A safely unwrapped
|
||||
// slash invocation is useful display metadata, though: it lets the
|
||||
// frontend restore the selected skill/subagent in history and trash.
|
||||
if replay := control.StripComposePrefixes(m.Content); strings.HasPrefix(strings.TrimSpace(replay), "/") && replay != content {
|
||||
hm.SubmitText = replay
|
||||
}
|
||||
} else {
|
||||
hm.SubmitText = m.Content
|
||||
}
|
||||
}
|
||||
if m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {
|
||||
hm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer, composerPickFileEntry } from "../components/Composer";
|
||||
import { UserMessage } from "../components/Message";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
@@ -1038,6 +1039,7 @@ console.log("\ncomposer goal toggle");
|
||||
const dom = installDom();
|
||||
let commandsCalls = 0;
|
||||
let availableCommands: CommandInfo[] = [
|
||||
{ name: "mcp", description: "Manage MCP servers", kind: "builtin", group: "integrations" },
|
||||
{ name: "explore", description: "Investigate the codebase", kind: "subagent" },
|
||||
{ name: "superpowers:writing-plans", description: "Write a plan", kind: "skill", plugin: "superpowers" },
|
||||
{ name: "toolbox:writing-plans", description: "Write another plan", kind: "skill", plugin: "toolbox" },
|
||||
@@ -1049,7 +1051,7 @@ console.log("\ncomposer goal toggle");
|
||||
return availableCommands;
|
||||
},
|
||||
});
|
||||
const { root, rerender } = await renderComposer({ workspaceScopeKey: "runtime-0" });
|
||||
const { root, calls, rerender } = await renderComposer({ workspaceScopeKey: "runtime-0" });
|
||||
|
||||
await waitFor("plugin commands loaded", () => commandsCalls > 0);
|
||||
await replaceComposerDraft(rerender, 2000, "/m");
|
||||
@@ -1079,7 +1081,43 @@ console.log("\ncomposer goal toggle");
|
||||
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(textarea.value, "/superpowers:writing-plans ", "selecting a plugin skill inserts its qualified name");
|
||||
eq(textarea.value, "", "selecting a plugin skill keeps the task input separate from the invocation");
|
||||
ok(document.querySelector(".invocation-display--composer")?.textContent?.includes("Writing Plans") === true, "selected skill renders as composer context");
|
||||
ok(document.querySelector(".invocation-display--composer")?.textContent?.includes("superpowers") === true, "selected plugin skill keeps its source visible");
|
||||
ok(document.querySelector(".composer__content > .invocation-display--composer + textarea") !== null, "selected skill lives inside the task body column above the textarea");
|
||||
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Backspace", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".invocation-display--composer") === null, "Backspace removes a selected skill from an empty task input");
|
||||
|
||||
await replaceComposerDraft(rerender, 2002, "/writing-plans");
|
||||
await waitFor("skill menu after removal", () => Boolean(document.querySelector(".slashmenu")));
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
await replaceComposerDraft(rerender, 2003, "Draft the release plan");
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("composer send button did not render");
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.send[0], "Draft the release plan", "selected skill keeps the visible transcript text clean");
|
||||
eq(calls.submit[0], "/superpowers:writing-plans Draft the release plan", "selected skill preserves the provider-visible slash invocation");
|
||||
ok(document.querySelector(".invocation-display--composer") === null, "selected skill clears after send");
|
||||
|
||||
await replaceComposerDraft(rerender, 2004, "/mcp");
|
||||
await waitFor("builtin command menu", () => Boolean(document.querySelector(".slashmenu")));
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(textarea.value, "/mcp ", "management commands keep the existing inline argument flow");
|
||||
ok(document.querySelector(".invocation-display--composer") === null, "management commands do not become selected abilities");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
@@ -1087,5 +1125,72 @@ console.log("\ncomposer goal toggle");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<UserMessage
|
||||
id="h1"
|
||||
text="Draft the release plan"
|
||||
submitText="/superpowers:writing-plans Draft the release plan"
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".invocation-display--message")?.textContent?.includes("Writing Plans") === true, "restored history renders the selected skill header");
|
||||
ok(document.querySelector(".invocation-display--message")?.textContent?.includes("superpowers") === true, "restored history retains plugin source from the qualified command");
|
||||
eq(document.querySelector(".msg__text")?.textContent, "Draft the release plan", "history message keeps slash syntax out of the task body");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<UserMessage
|
||||
id="h2"
|
||||
text="Format this file"
|
||||
submitText={"以下是用户引用的历史会话上下文:\n\n[会话:Earlier]\n...\n\n---\n\n当前用户问题:\n/my-formatter Format this file"}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".invocation-display--message")?.textContent?.includes("My Formatter") === true, "history and trash previews recover selected abilities after referenced-session context");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<UserMessage
|
||||
id="h3"
|
||||
text={"Compare these commands\n/other-command"}
|
||||
submitText={"/reasonix-develop Compare these commands\n/other-command"}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".invocation-display--message")?.textContent?.includes("Reasonix Develop") === true, "history recovery ignores slash-prefixed lines inside the task body");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<UserMessage
|
||||
id="h4"
|
||||
text={"Compare these commands\n/other-command"}
|
||||
submitText={"以下是用户引用的历史会话上下文:\n\n[会话:Earlier]\n...\n\n---\n\n当前用户问题:\nCompare these commands\n/other-command"}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".invocation-display--message") === null, "ordinary referenced-session text does not turn task slash lines into a skill header");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -35,5 +35,11 @@ eq(
|
||||
"messages without hidden submit context use the rebuilt submit text",
|
||||
);
|
||||
|
||||
eq(
|
||||
replaySubmitText("/reasonix-develop review this change", "review this change", "review the updated change", "review the updated change"),
|
||||
"/reasonix-develop review the updated change",
|
||||
"editing a structured skill message preserves its slash invocation",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -9,6 +9,7 @@ import { canUsePromptHistory, isFnKeyEvent, promptHistoryDirectionFromEvent } fr
|
||||
import { cacheGeneration, loadOlder } from "../lib/composerHistory";
|
||||
import { SPINNER_WORDS, useI18n } from "../lib/i18n";
|
||||
import { detectShortcutPlatform, formatShortcutCombo, matchesShortcut } from "../lib/keyboardShortcuts";
|
||||
import { commandUsesStructuredInvocation, invocationDisplayForCommand } from "../lib/invocationDisplay";
|
||||
import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
|
||||
import { createRafResizeUpdater } from "../lib/resizeDrag";
|
||||
import { useToast } from "../lib/toast";
|
||||
@@ -28,6 +29,7 @@ import { Tooltip } from "./Tooltip";
|
||||
import { ComposerContextCard } from "./ComposerContextCard";
|
||||
import { ContextWindowRing } from "./ContextWindowRing";
|
||||
import { ImageViewer } from "./ImageViewer";
|
||||
import { InvocationBadge } from "./InvocationBadge";
|
||||
import { VirtualMenu } from "./VirtualMenu";
|
||||
import { dirEntryMenuLabel, dirEntrySubmitPath } from "./FileReferenceMenu";
|
||||
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
|
||||
@@ -57,6 +59,7 @@ const COMPOSER_MAX_HEIGHT = 360;
|
||||
const COMPOSER_RUN_STRIP_RESERVED = 30;
|
||||
const COMPOSER_MAX_VIEWPORT_RATIO = 0.4;
|
||||
const COMPOSER_AUTO_RESERVED_HEIGHT = 58;
|
||||
const COMPOSER_INVOCATION_RESERVED_HEIGHT = 32;
|
||||
const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
|
||||
// Grace after compositionend to swallow a confirm-Enter that lands just after
|
||||
// it; the real gap is a few ms, so keep it short or a deliberate quick second
|
||||
@@ -82,6 +85,7 @@ type FileRefSearchCacheEntry = {
|
||||
|
||||
type ComposerDraft = {
|
||||
text: string;
|
||||
selectedInvocation: CommandInfo | null;
|
||||
attachments: Attachment[];
|
||||
workspaceRefs: WorkspaceReference[];
|
||||
pastedBlocks: PastedBlock[];
|
||||
@@ -173,6 +177,7 @@ export function composerPickFileEntry(
|
||||
function emptyComposerDraft(): ComposerDraft {
|
||||
return {
|
||||
text: "",
|
||||
selectedInvocation: null,
|
||||
attachments: [],
|
||||
workspaceRefs: [],
|
||||
pastedBlocks: [],
|
||||
@@ -200,6 +205,7 @@ function guidanceTextMatches(queued: string, consumed: string): boolean {
|
||||
function cloneComposerDraft(draft: ComposerDraft): ComposerDraft {
|
||||
return {
|
||||
text: draft.text,
|
||||
selectedInvocation: draft.selectedInvocation ? { ...draft.selectedInvocation } : null,
|
||||
attachments: [...draft.attachments],
|
||||
workspaceRefs: [...draft.workspaceRefs],
|
||||
pastedBlocks: [...draft.pastedBlocks],
|
||||
@@ -319,8 +325,8 @@ function clampComposerHeight(height: number): number {
|
||||
return Math.min(Math.max(Math.round(height), COMPOSER_MIN_HEIGHT), composerMaxHeight());
|
||||
}
|
||||
|
||||
function composerAutoInputMaxHeight(): number {
|
||||
return Math.max(32, composerMaxHeight() - COMPOSER_AUTO_RESERVED_HEIGHT);
|
||||
function composerAutoInputMaxHeight(extraReservedHeight = 0): number {
|
||||
return Math.max(32, composerMaxHeight() - COMPOSER_AUTO_RESERVED_HEIGHT - extraReservedHeight);
|
||||
}
|
||||
|
||||
function loadComposerHeight(): number | null {
|
||||
@@ -609,6 +615,7 @@ export function Composer({
|
||||
}, []);
|
||||
|
||||
const [workspaceRefs, setWorkspaceRefs] = useState<WorkspaceReference[]>([]);
|
||||
const [selectedInvocation, setSelectedInvocation] = useState<CommandInfo | null>(null);
|
||||
const [pastedBlocks, setPastedBlocks] = useState<PastedBlock[]>([]);
|
||||
const [openPastedLabels, setOpenPastedLabels] = useState<string[]>([]);
|
||||
const [pendingPaste, setPendingPaste] = useState(0);
|
||||
@@ -681,11 +688,13 @@ export function Composer({
|
||||
const draftsBySessionRef = useRef<Record<string, ComposerDraft>>({});
|
||||
const activeDraftKeyRef = useRef(draftKey);
|
||||
const textRef = useRef(text);
|
||||
const selectedInvocationRef = useRef(selectedInvocation);
|
||||
const attachmentsRef = useRef(attachments);
|
||||
const workspaceRefsRef = useRef(workspaceRefs);
|
||||
const openPastedLabelsRef = useRef(openPastedLabels);
|
||||
const sessionRefsRef = useRef(sessionRefs);
|
||||
textRef.current = text;
|
||||
selectedInvocationRef.current = selectedInvocation;
|
||||
attachmentsRef.current = attachments;
|
||||
workspaceRefsRef.current = workspaceRefs;
|
||||
pastedBlocksRef.current = pastedBlocks;
|
||||
@@ -699,6 +708,7 @@ export function Composer({
|
||||
|
||||
const snapshotComposerDraft = (): ComposerDraft => ({
|
||||
text: textRef.current,
|
||||
selectedInvocation: selectedInvocationRef.current ? { ...selectedInvocationRef.current } : null,
|
||||
attachments: [...attachmentsRef.current],
|
||||
workspaceRefs: [...workspaceRefsRef.current],
|
||||
pastedBlocks: [...pastedBlocksRef.current],
|
||||
@@ -718,11 +728,13 @@ export function Composer({
|
||||
const restoreComposerDraft = (draft: ComposerDraft) => {
|
||||
const next = cloneComposerDraft(draft);
|
||||
textRef.current = next.text;
|
||||
selectedInvocationRef.current = next.selectedInvocation;
|
||||
attachmentsRef.current = next.attachments;
|
||||
workspaceRefsRef.current = next.workspaceRefs;
|
||||
openPastedLabelsRef.current = next.openPastedLabels;
|
||||
sessionRefsRef.current = next.sessionRefs;
|
||||
setText(next.text);
|
||||
setSelectedInvocation(next.selectedInvocation);
|
||||
setAttachments(next.attachments);
|
||||
setWorkspaceRefs(next.workspaceRefs);
|
||||
pastedBlocksRef.current = next.pastedBlocks;
|
||||
@@ -1351,6 +1363,8 @@ export function Composer({
|
||||
if (targetDraftKey === activeDraftKeyRef.current) {
|
||||
textRef.current = "";
|
||||
setText("");
|
||||
selectedInvocationRef.current = null;
|
||||
setSelectedInvocation(null);
|
||||
historyIndexRef.current = -1;
|
||||
setHistoryIndex(-1);
|
||||
clearAttachments();
|
||||
@@ -1367,6 +1381,7 @@ export function Composer({
|
||||
}
|
||||
const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
|
||||
draft.text = "";
|
||||
draft.selectedInvocation = null;
|
||||
draft.attachments = [];
|
||||
draft.workspaceRefs = [];
|
||||
draft.pastedBlocks = [];
|
||||
@@ -1483,7 +1498,8 @@ export function Composer({
|
||||
const currentSessionRefs = sessionRefsRef.current;
|
||||
const currentPastedBlocks = [...pastedBlocksRef.current];
|
||||
const sessionContext = currentSessionRefs.length === 0 ? "" : await buildSessionContext(currentSessionRefs);
|
||||
const baseSubmitText = [expandPastedBlocks(trimmedText, currentPastedBlocks), refs].filter(Boolean).join(trimmedText && refs ? " " : "");
|
||||
const invocationPrefix = selectedInvocationRef.current ? `/${selectedInvocationRef.current.name}` : "";
|
||||
const baseSubmitText = [invocationPrefix, expandPastedBlocks(trimmedText, currentPastedBlocks), refs].filter(Boolean).join(" ");
|
||||
const submitText = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
|
||||
if (running) {
|
||||
const guidanceText = displayText.trim();
|
||||
@@ -1963,7 +1979,22 @@ export function Composer({
|
||||
setTextCaretEnd([base, ...queued].filter((part) => part.trim() !== "").join("\n"));
|
||||
};
|
||||
|
||||
const pickCommand = (c: CommandInfo) => setTextCaretEnd("/" + c.name + " ");
|
||||
const clearSelectedInvocation = () => {
|
||||
selectedInvocationRef.current = null;
|
||||
setSelectedInvocation(null);
|
||||
requestAnimationFrame(() => taRef.current?.focus());
|
||||
};
|
||||
|
||||
const pickCommand = (c: CommandInfo) => {
|
||||
if (!commandUsesStructuredInvocation(c)) {
|
||||
setTextCaretEnd("/" + c.name + " ");
|
||||
return;
|
||||
}
|
||||
selectedInvocationRef.current = c;
|
||||
setSelectedInvocation(c);
|
||||
textRef.current = "";
|
||||
setTextCaretEnd("");
|
||||
};
|
||||
|
||||
const activePastedBlocks = pastedBlocks.filter((block) => text.includes(block.label));
|
||||
const shellModeActive = text.trimStart().startsWith("!");
|
||||
@@ -2010,13 +2041,13 @@ export function Composer({
|
||||
if (!node) return;
|
||||
const previousHeight = node.style.height;
|
||||
node.style.height = "auto";
|
||||
const maxHeight = composerAutoInputMaxHeight();
|
||||
const maxHeight = composerAutoInputMaxHeight(selectedInvocationRef.current ? COMPOSER_INVOCATION_RESERVED_HEIGHT : 0);
|
||||
const nextHeight = Math.min(node.scrollHeight, maxHeight);
|
||||
const nextOverflow = node.scrollHeight > maxHeight + 1;
|
||||
node.style.height = previousHeight;
|
||||
setTextareaAutoHeight((current) => (current === nextHeight ? current : nextHeight));
|
||||
setTextareaAutoOverflow((current) => (current === nextOverflow ? current : nextOverflow));
|
||||
}, [composerHeight]);
|
||||
}, [composerHeight, selectedInvocation]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measureTextareaAutoHeight();
|
||||
@@ -2302,6 +2333,19 @@ export function Composer({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
e.key === "Backspace" &&
|
||||
!composing &&
|
||||
selectedInvocationRef.current &&
|
||||
e.currentTarget.value === "" &&
|
||||
e.currentTarget.selectionStart === 0 &&
|
||||
e.currentTarget.selectionEnd === 0
|
||||
) {
|
||||
e.preventDefault();
|
||||
clearSelectedInvocation();
|
||||
return;
|
||||
}
|
||||
|
||||
syncPromptHistoryGeneration();
|
||||
|
||||
const canUseCurrentPromptHistory = () => canUsePromptHistory({
|
||||
@@ -2453,7 +2497,9 @@ export function Composer({
|
||||
? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties)
|
||||
: undefined;
|
||||
const composerAutoExpanded = composerHeight === null && textareaAutoHeight !== null && textareaAutoHeight > 40;
|
||||
const composerResizeValue = composerHeight ?? clampComposerHeight((textareaAutoHeight ?? 0) + COMPOSER_AUTO_RESERVED_HEIGHT);
|
||||
const composerResizeValue = composerHeight ?? clampComposerHeight(
|
||||
(textareaAutoHeight ?? 0) + COMPOSER_AUTO_RESERVED_HEIGHT + (selectedInvocation ? COMPOSER_INVOCATION_RESERVED_HEIGHT : 0),
|
||||
);
|
||||
void onSetMode;
|
||||
const chooseApprovalMode = (nextMode: ToolApprovalMode) => {
|
||||
onSetToolApprovalMode(nextMode);
|
||||
@@ -3045,69 +3091,85 @@ export function Composer({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`composer${dragOver ? " composer--dragover" : ""}${disabled || readOnly ? " composer--disabled" : ""}${shellModeActive ? " composer--shell" : ""}`}
|
||||
className={`composer${selectedInvocation ? " composer--has-invocation" : ""}${dragOver ? " composer--dragover" : ""}${disabled || readOnly ? " composer--disabled" : ""}${shellModeActive ? " composer--shell" : ""}`}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
>
|
||||
<span className="composer__caret">{shellModeActive ? "$" : "›"}</span>
|
||||
<textarea
|
||||
id="composer-input"
|
||||
ref={taRef}
|
||||
className="composer__input"
|
||||
aria-label={t("composer.placeholder")}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
resetPromptHistoryNavigation();
|
||||
setText(e.target.value);
|
||||
if (composerPrompt) setComposerPrompt(null);
|
||||
}}
|
||||
onSelect={rememberCaret}
|
||||
onClick={rememberCaret}
|
||||
onKeyUp={rememberCaret}
|
||||
onFocus={rememberCaret}
|
||||
onContextMenu={openInputMenu}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false;
|
||||
lastCompositionEndAt.current = Date.now();
|
||||
}}
|
||||
style={textareaStyle}
|
||||
placeholder={composerPlaceholder}
|
||||
rows={1}
|
||||
disabled={disabled || readOnly}
|
||||
/>
|
||||
{composerPrompt && (
|
||||
<span className="composer__prompt" role="status">
|
||||
{composerPrompt}
|
||||
</span>
|
||||
)}
|
||||
{running && (
|
||||
<Tooltip label={t("composer.stop")}>
|
||||
<div className="composer__input-row">
|
||||
<span className="composer__caret">{shellModeActive ? "$" : "›"}</span>
|
||||
<div className="composer__content">
|
||||
{selectedInvocation && (() => {
|
||||
const invocation = invocationDisplayForCommand(selectedInvocation);
|
||||
return (
|
||||
<InvocationBadge
|
||||
invocation={invocation}
|
||||
kind={selectedInvocation.kind === "subagent" ? "subagent" : "skill"}
|
||||
description={selectedInvocation.description}
|
||||
onRemove={clearSelectedInvocation}
|
||||
variant="composer"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<textarea
|
||||
id="composer-input"
|
||||
ref={taRef}
|
||||
className="composer__input"
|
||||
aria-label={t("composer.placeholder")}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
resetPromptHistoryNavigation();
|
||||
setText(e.target.value);
|
||||
if (composerPrompt) setComposerPrompt(null);
|
||||
}}
|
||||
onSelect={rememberCaret}
|
||||
onClick={rememberCaret}
|
||||
onKeyUp={rememberCaret}
|
||||
onFocus={rememberCaret}
|
||||
onContextMenu={openInputMenu}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false;
|
||||
lastCompositionEndAt.current = Date.now();
|
||||
}}
|
||||
style={textareaStyle}
|
||||
placeholder={composerPlaceholder}
|
||||
rows={1}
|
||||
disabled={disabled || readOnly}
|
||||
/>
|
||||
</div>
|
||||
{composerPrompt && (
|
||||
<span className="composer__prompt" role="status">
|
||||
{composerPrompt}
|
||||
</span>
|
||||
)}
|
||||
{running && (
|
||||
<Tooltip label={t("composer.stop")}>
|
||||
<button
|
||||
className="composer__btn composer__btn--stop"
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
aria-label={t("composer.stop")}
|
||||
>
|
||||
<Square size={12} fill="currentColor" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={submitTooltip}>
|
||||
<button
|
||||
className="composer__btn composer__btn--stop"
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
aria-label={t("composer.stop")}
|
||||
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
|
||||
onClick={submit}
|
||||
disabled={submitBlocked}
|
||||
aria-label={submitTooltip}
|
||||
>
|
||||
<Square size={12} fill="currentColor" />
|
||||
{running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={submitTooltip}>
|
||||
<button
|
||||
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
|
||||
onClick={submit}
|
||||
disabled={submitBlocked}
|
||||
aria-label={submitTooltip}
|
||||
>
|
||||
{running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu
|
||||
open={inputMenuPoint !== null}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Box, X } from "lucide-react";
|
||||
import type { InvocationDisplay } from "../lib/invocationDisplay";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
export function InvocationBadge({
|
||||
invocation,
|
||||
kind = "skill",
|
||||
description,
|
||||
onRemove,
|
||||
variant,
|
||||
}: {
|
||||
invocation: InvocationDisplay;
|
||||
kind?: "skill" | "subagent";
|
||||
description?: string;
|
||||
onRemove?: () => void;
|
||||
variant: "composer" | "message";
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className={`invocation-display invocation-display--${variant} invocation-display--${kind}`} role="group" aria-label={t("composer.selectedInvocation")}>
|
||||
<Tooltip label={description || `/${invocation.name}`}>
|
||||
<span className="invocation-display__identity">
|
||||
<Box size={variant === "composer" ? 19 : 17} />
|
||||
<span className="invocation-display__name">{invocation.label}</span>
|
||||
{invocation.source && <span className="invocation-display__source">{t("slash.plugin", { name: invocation.source })}</span>}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{onRemove && (
|
||||
<Tooltip label={t("composer.removeInvocation")}>
|
||||
<button
|
||||
type="button"
|
||||
className="invocation-display__remove"
|
||||
onClick={onRemove}
|
||||
aria-label={t("composer.removeInvocationNamed", { name: invocation.label })}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,10 @@ import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import { displayReasoningText } from "../lib/reasoningDisplay";
|
||||
import { stripMemoryCompilerExecution } from "../lib/memoryCompilerDisplay";
|
||||
import { visibleTranscriptMemoryCitations } from "../lib/memoryCitationVisibility";
|
||||
import { invocationDisplayFromMessage } from "../lib/invocationDisplay";
|
||||
import type { Item, MessageActionScope } from "../lib/useController";
|
||||
import type { CheckpointMeta, MemoryCitation } from "../lib/types";
|
||||
import { InvocationBadge } from "./InvocationBadge";
|
||||
|
||||
type AssistantItem = Extract<Item, { kind: "assistant" }>;
|
||||
export type TurnActionMenu = "summary" | "rewind";
|
||||
@@ -197,6 +199,7 @@ export function UserMessage({
|
||||
const actionText = stripMemoryCompilerExecution(imSource?.text ?? text);
|
||||
const hasMemoryCompiler = Boolean(submitText?.includes("<memory-compiler-execution>"));
|
||||
const { text: displayText, attachments } = parseAttachmentRefsForDisplay(actionText);
|
||||
const invocation = imSource ? null : invocationDisplayFromMessage(displayText, submitText);
|
||||
const orderedAttachments = sortDisplayAttachments(attachments);
|
||||
const sourceLabel = imSource ? imSourceLabel(imSource, t) : "";
|
||||
const sentAt = createdAt === undefined ? null : messageDate(createdAt);
|
||||
@@ -379,6 +382,7 @@ export function UserMessage({
|
||||
data-entrance={id || undefined}
|
||||
>
|
||||
<div className={`msg__body${editing ? " msg__body--editing" : ""}`}>
|
||||
{invocation && <InvocationBadge invocation={invocation} variant="message" />}
|
||||
{editing ? (
|
||||
<form className="msg-edit" onSubmit={(event) => void submitEdit(event)}>
|
||||
{orderedDraftAttachments.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CommandInfo } from "./types";
|
||||
|
||||
export type InvocationDisplay = {
|
||||
name: string;
|
||||
label: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export function commandUsesStructuredInvocation(command: CommandInfo): boolean {
|
||||
return command.kind === "skill" || command.kind === "subagent";
|
||||
}
|
||||
|
||||
export function invocationLabel(name: string): string {
|
||||
const unqualified = name.split(":").pop() || name;
|
||||
return unqualified
|
||||
.split(/[-_.]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function invocationDisplayForCommand(command: CommandInfo): InvocationDisplay {
|
||||
return {
|
||||
name: command.name,
|
||||
label: invocationLabel(command.name),
|
||||
source: command.plugin || command.name.split(":").slice(0, -1).join(":") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function invocationDisplayFromMessage(displayText: string, submitText?: string): InvocationDisplay | null {
|
||||
const display = displayText.trim();
|
||||
const submit = submitText?.trim() ?? "";
|
||||
if (!submit || submit === display || /^\/[A-Za-z0-9_.:-]+(?:\s|$)/.test(display)) return null;
|
||||
|
||||
const sessionQuestionMarker = "当前用户问题:\n";
|
||||
const markerIndex = submit.lastIndexOf(sessionQuestionMarker);
|
||||
const invocationText = (markerIndex >= 0 ? submit.slice(markerIndex + sessionQuestionMarker.length) : submit).trimStart();
|
||||
const match = markerIndex >= 0
|
||||
? /^\/([A-Za-z0-9_.:-]+)(?=\s|$)/.exec(invocationText)
|
||||
: /(?:^|\n)\/([A-Za-z0-9_.:-]+)(?=\s|$)/.exec(invocationText);
|
||||
if (!match) return null;
|
||||
if (markerIndex < 0) {
|
||||
const slashOffset = match.index + match[0].lastIndexOf("/");
|
||||
const prefix = invocationText.slice(0, slashOffset).trim();
|
||||
if (prefix && !prefix.startsWith("<") && !prefix.startsWith("[Plan mode")) return null;
|
||||
}
|
||||
const name = match[1];
|
||||
return {
|
||||
name,
|
||||
label: invocationLabel(name),
|
||||
source: name.includes(":") ? name.split(":").slice(0, -1).join(":") : undefined,
|
||||
};
|
||||
}
|
||||
@@ -572,6 +572,9 @@ export const en = {
|
||||
"composer.pasteImageFailed": "Could not read clipboard image",
|
||||
"composer.imageInputUnsupported": "Current model will not receive images directly. Use an image-capable model or an OCR/vision MCP tool to inspect the attached path.",
|
||||
"composer.contextItems": "Context items",
|
||||
"composer.selectedInvocation": "Selected skill or subagent",
|
||||
"composer.removeInvocation": "Remove selected skill or subagent",
|
||||
"composer.removeInvocationNamed": "Remove {name}",
|
||||
"composer.workspaceReferences": "Workspace references",
|
||||
"composer.removeReference": "Remove reference",
|
||||
|
||||
|
||||
@@ -445,6 +445,9 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"imageViewer.title": "圖片預覽",
|
||||
"composer.imageInputUnsupported": "目前模型不會直接接收圖片。可切換到支援圖片的模型,或讓 OCR/識圖 MCP 工具讀取附件路徑。",
|
||||
"composer.contextItems": "上下文項目",
|
||||
"composer.selectedInvocation": "已選擇的技能或子智慧體",
|
||||
"composer.removeInvocation": "移除已選擇的技能或子智慧體",
|
||||
"composer.removeInvocationNamed": "移除 {name}",
|
||||
"composer.workspaceReferences": "工作區引用",
|
||||
"composer.removeReference": "移除引用",
|
||||
|
||||
|
||||
@@ -573,6 +573,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"composer.pasteImageFailed": "未能读取剪贴板图片",
|
||||
"composer.imageInputUnsupported": "当前模型不会直接接收图片。可切换到支持图片的模型,或让 OCR/识图 MCP 工具读取附件路径。",
|
||||
"composer.contextItems": "上下文项目",
|
||||
"composer.selectedInvocation": "已选择的技能或子智能体",
|
||||
"composer.removeInvocation": "移除已选择的技能或子智能体",
|
||||
"composer.removeInvocationNamed": "移除 {name}",
|
||||
"composer.workspaceReferences": "工作区引用",
|
||||
"composer.removeReference": "移除引用",
|
||||
|
||||
|
||||
@@ -5704,8 +5704,9 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -5716,8 +5717,118 @@ body > .mermaid-diagram--fullscreen {
|
||||
.composer-card--resized .composer,
|
||||
.composer-card--autosized .composer {
|
||||
flex: 1;
|
||||
}
|
||||
.composer__input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.composer__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.composer-card--resized .composer__input-row,
|
||||
.composer-card--autosized .composer__input-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.composer--has-invocation .composer__caret {
|
||||
margin-top: 32px;
|
||||
}
|
||||
.invocation-display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: var(--accent);
|
||||
}
|
||||
.invocation-display--composer {
|
||||
align-self: flex-start;
|
||||
height: 27px;
|
||||
}
|
||||
.invocation-display--message {
|
||||
margin-bottom: 6px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.invocation-display--message .invocation-display__name {
|
||||
font-size: 13px;
|
||||
font-weight: 620;
|
||||
}
|
||||
.invocation-display--message .invocation-display__source {
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.invocation-display__identity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.invocation-display__identity > svg {
|
||||
flex: 0 0 auto;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
.invocation-display__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--accent);
|
||||
font-size: 15px;
|
||||
font-weight: 590;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.invocation-display__source {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--fg-faint);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.invocation-display__remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--fg-faint);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease, color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.invocation-display:hover .invocation-display__remove,
|
||||
.invocation-display:focus-within .invocation-display__remove {
|
||||
opacity: 1;
|
||||
}
|
||||
.invocation-display__remove:hover,
|
||||
.invocation-display__remove:focus-visible {
|
||||
outline: none;
|
||||
background: var(--hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.invocation-display__remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.composer--dragover {
|
||||
outline: 1px dashed var(--accent);
|
||||
outline-offset: -4px;
|
||||
@@ -5749,6 +5860,7 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
.composer__input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
border: none;
|
||||
background: none;
|
||||
@@ -5765,7 +5877,8 @@ body > .mermaid-diagram--fullscreen {
|
||||
display: none; /* Chrome / Safari / Wails webview */
|
||||
}
|
||||
.composer-card--resized .composer__input {
|
||||
height: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin; /* restore scrollbar when resized */
|
||||
|
||||
@@ -91,6 +91,21 @@ func TestHistoryMessagesDoNotReplayMemoryCompilerContract(t *testing.T) {
|
||||
assertNoHistoryMemoryContract(t, got[0].Content)
|
||||
}
|
||||
|
||||
func TestHistoryMessagesRestoreCompiledSkillInvocationWithoutContract(t *testing.T) {
|
||||
raw := historyMemoryCompilerContract(t, "/reasonix-develop ship the refactor")
|
||||
msgs := []provider.Message{{Role: provider.RoleUser, Content: raw}}
|
||||
|
||||
got := historyMessages(msgs, func(string) string { return "ship the refactor" })
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("history length = %d, want 1: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Content != "ship the refactor" || got[0].SubmitText != "/reasonix-develop ship the refactor" {
|
||||
t.Fatalf("compiled skill history = %+v", got[0])
|
||||
}
|
||||
assertNoHistoryMemoryContract(t, got[0].Content)
|
||||
assertNoHistoryMemoryContract(t, got[0].SubmitText)
|
||||
}
|
||||
|
||||
func TestHistoryMessagesStripActiveGoalFromVisibleUserContent(t *testing.T) {
|
||||
raw := "<active-goal>\nship the approval redesign\n</active-goal>\n\ncontinue implementation"
|
||||
msgs := []provider.Message{
|
||||
|
||||
Reference in new issue
Block a user