diff --git a/ui/src/components/icons.ts b/ui/src/components/icons.ts
index 4ae6f94486ec..b058ca97df4a 100644
--- a/ui/src/components/icons.ts
+++ b/ui/src/components/icons.ts
@@ -220,6 +220,16 @@ export const icons = {
d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
/>
`),
+ pencilSparkles: strokeIcon(svg`
+
+
+
+
+
+
+ `),
hourglass: strokeIcon(svg`
diff --git a/ui/src/e2e/chat-skill-references.e2e.test.ts b/ui/src/e2e/chat-skill-references.e2e.test.ts
index a350ef86ab88..81b350091778 100644
--- a/ui/src/e2e/chat-skill-references.e2e.test.ts
+++ b/ui/src/e2e/chat-skill-references.e2e.test.ts
@@ -1,5 +1,6 @@
// Control UI E2E tests cover composable skill references in the chat composer.
import path from "node:path";
+import type { Locator } from "playwright";
import { expect, it } from "vitest";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@@ -8,6 +9,22 @@ const suite = createControlUiE2eSuite({
name: "Control UI skill references",
});
+async function setComposerCaret(composer: Locator, caret: number) {
+ await composer.evaluate((element, position) => {
+ if (!(element instanceof HTMLTextAreaElement)) {
+ throw new Error("Chat composer is not a textarea");
+ }
+ element.setSelectionRange(position, position);
+ }, caret);
+}
+
+type FriendlyTokenGeometry = {
+ clipped: number;
+ label?: string;
+ overlapsNextWord: boolean;
+ overflow: number;
+};
+
suite.define(() => {
it("references multiple skills inside a normal prompt and sends the visible tokens", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
@@ -30,6 +47,16 @@ suite.define(() => {
skillModelVisible: true,
textAliases: ["/autoreview"],
},
+ {
+ acceptsArgs: true,
+ description: "Exercise friendly token labels.",
+ name: "bench_skill_01",
+ skillDisplayName: "Bench Skill 01",
+ scope: "both",
+ source: "skill",
+ skillModelVisible: true,
+ textAliases: ["/bench_skill_01"],
+ },
{
acceptsArgs: true,
description: "Build and review technical documentation.",
@@ -40,6 +67,26 @@ suite.define(() => {
skillModelVisible: true,
textAliases: ["/technical_documentation"],
},
+ {
+ acceptsArgs: true,
+ description: "Prepare a detailed status report.",
+ name: "status_report",
+ skillDisplayName: "Status Report",
+ scope: "both",
+ source: "skill",
+ skillModelVisible: true,
+ textAliases: ["/status_report"],
+ },
+ {
+ acceptsArgs: true,
+ description: "Exercise wrapped token geometry.",
+ name: "wrap",
+ skillDisplayName: "Extremely Detailed Wrapped Composition Helper",
+ scope: "both",
+ source: "skill",
+ skillModelVisible: true,
+ textAliases: ["/wrap"],
+ },
{
acceptsArgs: false,
description: "Show gateway status.",
@@ -112,7 +159,172 @@ suite.define(() => {
await expect.poll(() => picker.count()).toBe(0);
await composer.fill("/");
await page.getByRole("listbox", { name: "Slash commands" }).waitFor({ state: "visible" });
- await expect.poll(() => page.getByRole("option", { name: /\/status/u }).count()).toBe(1);
+ await expect.poll(() => page.getByRole("option", { name: /\/status/u }).count()).toBe(2);
+
+ const slashOptions = page
+ .getByRole("listbox", { name: "Slash commands" })
+ .getByRole("option");
+ await composer.fill("/sta");
+ await expect
+ .poll(async () => {
+ const names = await slashOptions.locator(".slash-menu-name").allTextContents();
+ return { first: names[0], last: names.at(-1) };
+ })
+ .toEqual({ first: "/status", last: "/status_report" });
+ await composer.press("Tab");
+ await expect.poll(() => composer.inputValue()).toBe("/status");
+
+ const readFriendlyTokenGeometry = () =>
+ page
+ .locator(".agent-chat__composer-draft-overlay")
+ .evaluate((element): FriendlyTokenGeometry[] => {
+ return Array.from(
+ element.querySelectorAll(".agent-chat__skill-token"),
+ ).map((token) => {
+ let sibling: ChildNode | null = token.nextSibling;
+ while (sibling && (!(sibling instanceof Text) || !/\S/u.test(sibling.data))) {
+ sibling = sibling.nextSibling;
+ }
+ if (!(sibling instanceof Text)) {
+ throw new Error("Expected text after skill token");
+ }
+ const nextWordStart = sibling.data.search(/\S/u);
+ const wordRange = document.createRange();
+ wordRange.setStart(sibling, nextWordStart);
+ wordRange.setEnd(sibling, nextWordStart + 1);
+ const wordRect = wordRange.getBoundingClientRect();
+ const tokenRect = token.getBoundingClientRect();
+ const horizontalOverlap =
+ Math.min(tokenRect.right, wordRect.right) -
+ Math.max(tokenRect.left, wordRect.left);
+ const verticalOverlap =
+ Math.min(tokenRect.bottom, wordRect.bottom) -
+ Math.max(tokenRect.top, wordRect.top);
+ return {
+ clipped: token.scrollWidth - token.clientWidth,
+ label: token.textContent?.trim(),
+ overlapsNextWord: horizontalOverlap > 0 && verticalOverlap > 0,
+ overflow:
+ Math.max(0, element.getBoundingClientRect().left - tokenRect.left) +
+ Math.max(0, tokenRect.right - element.getBoundingClientRect().right),
+ };
+ });
+ });
+
+ await composer.fill("Use $bench_skill_01 next and $autoreview later");
+ await expect
+ .poll(readFriendlyTokenGeometry)
+ .toSatisfy(
+ (tokens) =>
+ tokens?.length === 2 &&
+ tokens.every(
+ (token: FriendlyTokenGeometry) =>
+ token.clipped <= 1 &&
+ !token.overlapsNextWord &&
+ token.overflow <= 1 &&
+ ["Bench Skill 01", "Auto Review"].includes(token.label ?? ""),
+ ),
+ );
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await composer.fill(
+ "A mobile line that wraps before $bench_skill_01 next and $autoreview later",
+ );
+ await expect
+ .poll(readFriendlyTokenGeometry)
+ .toSatisfy(
+ (tokens) =>
+ tokens?.length === 2 &&
+ tokens.every(
+ (token: FriendlyTokenGeometry) =>
+ token.clipped <= 1 && !token.overlapsNextWord && token.overflow <= 1,
+ ),
+ );
+
+ await composer.fill("שלום עם $bench_skill_01 הבא ואז $autoreview מאוחר יותר");
+ await expect.poll(() => composer.getAttribute("dir")).toBe("rtl");
+ await expect
+ .poll(readFriendlyTokenGeometry)
+ .toSatisfy(
+ (tokens) =>
+ tokens?.length === 2 &&
+ tokens.every(
+ (token: FriendlyTokenGeometry) =>
+ token.clipped <= 1 && !token.overlapsNextWord && token.overflow <= 1,
+ ),
+ );
+
+ await page.setViewportSize({ width: 420, height: 780 });
+ await composer.fill(
+ "A wrapped prompt leading into $wrap: then following copy that must share the native textarea wrap.",
+ );
+ const overlay = page.locator(".agent-chat__composer-draft-overlay");
+ const token = overlay.locator(".agent-chat__skill-token");
+ await token.waitFor({ state: "visible" });
+ await expect.poll(() => token.getAttribute("data-raw")).toBe("$wrap");
+ await expect
+ .poll(() => overlay.textContent())
+ .toSatisfy((text) =>
+ text
+ ?.replace(/\s+/gu, " ")
+ .includes(
+ "A wrapped prompt leading into Extremely Detailed Wrapped Composition Helper: then",
+ ),
+ );
+ const [textareaGeometry, overlayGeometry] = await Promise.all([
+ composer.evaluate((element) => {
+ const style = getComputedStyle(element);
+ return {
+ clientWidth: element.clientWidth,
+ font: style.font,
+ lineHeight: style.lineHeight,
+ overflowWrap: style.overflowWrap,
+ paddingInline: style.paddingInline,
+ whiteSpace: style.whiteSpace,
+ };
+ }),
+ overlay.evaluate((element) => {
+ const style = getComputedStyle(element);
+ return {
+ clientWidth: element.clientWidth,
+ font: style.font,
+ lineHeight: style.lineHeight,
+ overflowWrap: style.overflowWrap,
+ paddingInline: style.paddingInline,
+ whiteSpace: style.whiteSpace,
+ };
+ }),
+ ]);
+ expect(overlayGeometry).toEqual(textareaGeometry);
+
+ await expect
+ .poll(() => overlay.evaluate((element) => getComputedStyle(element).whiteSpace))
+ .toBe("pre-wrap");
+
+ if (artifactDir) {
+ await page.locator(".agent-chat__composer-shell").screenshot({
+ path: path.join(artifactDir, "skill-reference-wrapped.png"),
+ });
+ }
+
+ const rtlDraft = "שלום עם $wrap: וטקסט נוסף שצריך להישאר מיושר עם שדה הכתיבה.";
+ const rtlTokenStart = rtlDraft.indexOf("$wrap");
+ const rtlTokenEnd = rtlTokenStart + "$wrap".length;
+ const rtlDraftWithoutToken = `${rtlDraft.slice(0, rtlTokenStart)}${rtlDraft.slice(
+ rtlTokenEnd,
+ )}`;
+ await composer.fill(rtlDraft);
+ await expect.poll(() => composer.getAttribute("dir")).toBe("rtl");
+ await expect.poll(() => overlay.getAttribute("dir")).toBe("rtl");
+ await token.waitFor({ state: "visible" });
+
+ await setComposerCaret(composer, rtlTokenEnd);
+ await composer.press("Backspace");
+ await expect.poll(() => composer.inputValue()).toBe(rtlDraftWithoutToken);
+ await composer.fill(rtlDraft);
+ await setComposerCaret(composer, rtlTokenStart);
+ await composer.press("Delete");
+ await expect.poll(() => composer.inputValue()).toBe(rtlDraftWithoutToken);
},
);
});
diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts
index b6112b9779a8..62700dba80ae 100644
--- a/ui/src/pages/chat/chat-responsive.browser.test.ts
+++ b/ui/src/pages/chat/chat-responsive.browser.test.ts
@@ -3611,6 +3611,39 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
}
});
+ it("keeps mobile slash command copy in separate grid tracks", async () => {
+ const page = await openBrowserPage(390, 844);
+ try {
+ await page.setContent(`
+
+ `);
+
+ const geometry = await page.locator(".slash-menu-copy").evaluate((copy) => {
+ const name = copy.querySelector(".slash-menu-name")!;
+ const description = copy.querySelector(".slash-menu-desc")!;
+ const nameRect = name.getBoundingClientRect();
+ const descriptionRect = description.getBoundingClientRect();
+ return {
+ copyOverflow: copy.scrollWidth - copy.clientWidth,
+ gap: descriptionRect.left - nameRect.right,
+ };
+ });
+
+ expect(geometry.copyOverflow).toBeLessThanOrEqual(1);
+ expect(geometry.gap).toBeGreaterThanOrEqual(8);
+ } finally {
+ await closeBrowserPage(page);
+ }
+ });
+
describe("slash command keyboard navigation", () => {
let page: Page;
@@ -3705,10 +3738,8 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
const items = Array.from({ length: 16 }, (_, index) => {
const active = index === 15 ? " slash-menu-item--active" : "";
return ``;
}).join("");
await page.setContent(`
diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts
index 45145e9cc989..f83eef1ef794 100644
--- a/ui/src/pages/chat/chat-view.test.ts
+++ b/ui/src/pages/chat/chat-view.test.ts
@@ -615,11 +615,7 @@ function getThinkingReasoningValueLabel(container: Element): string {
"[data-chat-thinking-preview-committed]:not([hidden]), " +
"[data-chat-thinking-preview-index]:not([hidden])",
);
- return (
- preview?.textContent?.trim() ??
- container.querySelector(".chat-controls__reasoning-value")?.textContent?.trim() ??
- ""
- );
+ return preview?.textContent?.trim() ?? "";
}
function requireElement(container: Element, selector: string, label: string): Element {
@@ -3857,6 +3853,153 @@ describe("chat slash menu accessibility", () => {
expect(onSlashIntent).toHaveBeenCalledOnce();
});
+ it("shows skills after commands in the slash picker and highlights typed prefixes", () => {
+ replaceSkillCommands({
+ key: "status_report",
+ skillDisplayName: "Status Report",
+ description: "Prepare a detailed status report.",
+ });
+ const { container } = createReactiveDraftHarness();
+
+ inputDraftAtEnd(container, "/sta");
+
+ const options = Array.from(container.querySelectorAll("[role='option']"));
+ const skillHeader = container.querySelector(
+ ".slash-menu-group--skills .slash-menu-group__label",
+ );
+ expect(options.length).toBeGreaterThan(1);
+ expect(options[0]?.textContent).toContain("/status");
+ expect(options.at(-1)?.textContent).toContain("/status_report");
+ expect(skillHeader?.textContent).toBe("Skills");
+ expect(options[0]?.querySelector("mark")?.textContent).toBe("sta");
+ expect(options.at(-1)?.querySelector("mark")?.textContent).toBe("sta");
+ });
+
+ it("uses the grouped slash menu order for keyboard selection", () => {
+ replaceSlashCommands([
+ {
+ key: "status-report",
+ name: "status-report",
+ description: "Prepare a status report.",
+ source: "skill",
+ skillModelVisible: true,
+ },
+ {
+ key: "status-check",
+ name: "status-check",
+ description: "Check current status.",
+ source: "plugin",
+ },
+ ]);
+ const harness = createSlashRerenderHarness();
+ let container = harness.inputAndRender(harness.container, "/status");
+
+ const optionNames = () =>
+ Array.from(container.querySelectorAll(".slash-menu [role='option']")).map(
+ (option) => option.querySelector(".slash-menu-name")?.textContent?.trim(),
+ );
+ expect(optionNames()).toEqual(["/status-check", "/status-report"]);
+
+ keydownComposer(container, "Enter");
+ container = harness.renderCurrent();
+ expect(container.querySelector("textarea")?.value).toBe("/status-check ");
+ });
+
+ it("dismisses invocation sheets on an outside pointer press", () => {
+ const { container } = createReactiveDraftHarness();
+ document.body.append(container);
+ inputDraftAtEnd(container, "/sta");
+ expect(container.querySelector(".slash-menu")).not.toBeNull();
+
+ document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
+
+ expect(container.querySelector(".slash-menu")).toBeNull();
+ container.remove();
+ });
+
+ it("renders confirmed skills as atomic tokens for pointer and arrow navigation", () => {
+ replaceSkillCommands({
+ key: "prose_writer",
+ skillDisplayName: "Prose Writer",
+ description: "Draft polished prose.",
+ });
+ const { container } = createReactiveDraftHarness();
+ inputDraftAtEnd(container, "Use $prose_writer: next");
+
+ const textarea = getComposerTextarea(container);
+ const token = container.querySelector(".agent-chat__skill-token");
+ expect(token?.textContent).toContain("Prose Writer");
+ expect(token?.getAttribute("data-raw")).toBe("$prose_writer");
+ expect(
+ container
+ .querySelector(".agent-chat__composer-draft-overlay")
+ ?.textContent?.replace(/\s+/gu, " ")
+ .includes("Use Prose Writer: next"),
+ ).toBe(true);
+ expect(textarea.classList.contains("agent-chat__composer-textarea--rich")).toBe(true);
+
+ textarea.setSelectionRange(8, 8);
+ textarea.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+ expect(textarea.selectionStart).toBe(4);
+
+ textarea.setSelectionRange("Use $prose_writer".length, "Use $prose_writer".length);
+ keydownComposer(container, "ArrowLeft");
+ expect(textarea.selectionStart).toBe(4);
+ textarea.setSelectionRange(4, 4);
+ keydownComposer(container, "ArrowRight");
+ expect(textarea.selectionStart).toBe("Use $prose_writer".length);
+
+ const tokenStart = 4;
+ const tokenEnd = "Use $prose_writer".length;
+ textarea.setSelectionRange(tokenEnd, tokenEnd);
+ const selectBackward = keydownComposer(container, "ArrowLeft", { shiftKey: true });
+ expect(selectBackward.defaultPrevented).toBe(true);
+ expect([textarea.selectionStart, textarea.selectionEnd, textarea.selectionDirection]).toEqual([
+ tokenStart,
+ tokenEnd,
+ "backward",
+ ]);
+
+ const contractForward = keydownComposer(container, "ArrowRight", { shiftKey: true });
+ expect(contractForward.defaultPrevented).toBe(true);
+ expect([textarea.selectionStart, textarea.selectionEnd]).toEqual([tokenEnd, tokenEnd]);
+
+ textarea.setSelectionRange(tokenStart, tokenStart);
+ const selectForward = keydownComposer(container, "ArrowRight", { shiftKey: true });
+ expect(selectForward.defaultPrevented).toBe(true);
+ expect([textarea.selectionStart, textarea.selectionEnd, textarea.selectionDirection]).toEqual([
+ tokenStart,
+ tokenEnd,
+ "forward",
+ ]);
+ });
+
+ it.each([
+ { key: "Backspace", boundary: "end" },
+ { key: "Delete", boundary: "start" },
+ ] as const)("handles $key as an atomic skill-token deletion", ({ key, boundary }) => {
+ replaceSkillCommands({
+ key: "prose_writer",
+ skillDisplayName: "Prose Writer",
+ description: "Draft polished prose.",
+ });
+ const { container } = createReactiveDraftHarness();
+ const draft = "Use $prose_writer: next";
+ inputDraftAtEnd(container, draft);
+
+ const textarea = getComposerTextarea(container);
+ const tokenStart = draft.indexOf("$prose_writer");
+ const tokenEnd = tokenStart + "$prose_writer".length;
+ const caret = boundary === "start" ? tokenStart : tokenEnd;
+ textarea.setSelectionRange(caret, caret);
+ const event = keydownComposer(container, key);
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(textarea.value).toBe("Use : next");
+ expect(textarea.selectionStart).toBe(tokenStart);
+ expect(container.querySelector(".agent-chat__skill-token")).toBeNull();
+ });
+
it("fills a selected $ skill without submitting the surrounding prompt", async () => {
replaceSkillCommands({
key: "prose_writer",
@@ -4325,7 +4468,7 @@ describe("chat slash menu accessibility", () => {
expect(listbox?.querySelector(`#${activeId}`)?.getAttribute("role")).toBe("option");
});
- it("removes instant implementation badges without hiding option counts", () => {
+ it("removes secondary implementation and option-count badges", () => {
const harness = createSlashRerenderHarness();
const container = harness.inputAndRender(harness.container, "/");
const stopOption = Array.from(
@@ -4334,7 +4477,7 @@ describe("chat slash menu accessibility", () => {
expect(stopOption).toBeDefined();
expect(stopOption?.querySelector(".slash-menu-badge")).toBeNull();
- expect(container.querySelector(".slash-menu-badge")).not.toBeNull();
+ expect(container.querySelector(".slash-menu-badge")).toBeNull();
});
it("shows every command directly without an expander or keyboard footer", () => {
diff --git a/ui/src/pages/chat/components/chat-composer-dom.ts b/ui/src/pages/chat/components/chat-composer-dom.ts
index b38232b25fee..51b2f6e591f5 100644
--- a/ui/src/pages/chat/components/chat-composer-dom.ts
+++ b/ui/src/pages/chat/components/chat-composer-dom.ts
@@ -124,6 +124,18 @@ export function replaceComposerPopoverAnchor(
return next;
}
+function syncTextareaOverlay(el: HTMLTextAreaElement): HTMLElement | null {
+ const overlay = el.parentElement?.querySelector(
+ ".agent-chat__composer-draft-overlay",
+ );
+ if (!overlay) {
+ return null;
+ }
+ overlay.scrollTop = el.scrollTop;
+ overlay.scrollLeft = el.scrollLeft;
+ return overlay;
+}
+
function updateTextareaOverflow(el: HTMLTextAreaElement) {
const scrollable = el.scrollHeight > el.clientHeight + 1;
// Two 16px fades need enough vertical runway not to overlap into a narrow
@@ -134,9 +146,26 @@ function updateTextareaOverflow(el: HTMLTextAreaElement) {
el.style.overflowY = scrollable ? "auto" : "hidden";
el.toggleAttribute("data-scroll-fade-top", fadeTop);
el.toggleAttribute("data-scroll-fade-bottom", fadeBottom);
+ const overlay = syncTextareaOverlay(el);
+ overlay?.toggleAttribute("data-scroll-fade-top", fadeTop);
+ overlay?.toggleAttribute("data-scroll-fade-bottom", fadeBottom);
}
export function adjustTextareaHeight(el: HTMLTextAreaElement) {
+ // A surface that declares the compact shape is a fixed CSS box: it holds one
+ // line whatever the draft is, so an inline height left by an earlier measured
+ // pass would silently outrank the stylesheet. Which shape a composer is in is
+ // declared in its markup, never inferred here from how much text it holds.
+ if (el.closest('[data-composer-layout="single-line"]')) {
+ el.style.height = "";
+ el.style.overflowY = "";
+ el.removeAttribute("data-scroll-fade-top");
+ el.removeAttribute("data-scroll-fade-bottom");
+ const overlay = syncTextareaOverlay(el);
+ overlay?.removeAttribute("data-scroll-fade-top");
+ overlay?.removeAttribute("data-scroll-fade-bottom");
+ return;
+ }
const thread = el.closest(".chat")?.querySelector(".chat-thread") ?? null;
const preserveBottomAnchor = thread
? captureChatSessionScrollPosition(thread).anchorToEnd
@@ -150,7 +179,10 @@ export function adjustTextareaHeight(el: HTMLTextAreaElement) {
const computedMaxHeight = getComputedStyle(el).maxHeight.trim();
const pixelMaxHeight = /^(\d+(?:\.\d+)?)px$/u.exec(computedMaxHeight);
const maxHeight = pixelMaxHeight ? Number(pixelMaxHeight[1]) : 150;
- el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
+ const overlay = el.parentElement?.querySelector(
+ ".agent-chat__composer-draft-overlay",
+ );
+ el.style.height = `${Math.min(Math.max(el.scrollHeight, overlay?.scrollHeight ?? 0), maxHeight)}px`;
updateTextareaOverflow(el);
// Once capped, the textarea can perturb the sibling transcript without
// resizing its viewport, so ResizeObserver has no correction to apply.
@@ -297,6 +329,22 @@ export function scrollActiveMenuOptionIntoView(activeId: string | null): void {
});
}
+export function syncComposerMenuScroll(element: Element | undefined): void {
+ if (!(element instanceof HTMLElement)) {
+ return;
+ }
+ const sync = () => {
+ const scrollable = element.scrollHeight > element.clientHeight + 1;
+ element.dataset.scrollable = String(scrollable);
+ element.dataset.atStart = String(!scrollable || element.scrollTop <= 1);
+ element.dataset.atEnd = String(
+ !scrollable || element.scrollTop + element.clientHeight >= element.scrollHeight - 1,
+ );
+ };
+ sync();
+ requestAnimationFrame(sync);
+}
+
export function paneDomId(paneId: string, suffix: string): string {
return `chat-${encodeURIComponent(paneId)}-${suffix}`;
}
diff --git a/ui/src/pages/chat/components/chat-composer-keydown.ts b/ui/src/pages/chat/components/chat-composer-keydown.ts
index c9df51d1d9d4..e73b3e9e370b 100644
--- a/ui/src/pages/chat/components/chat-composer-keydown.ts
+++ b/ui/src/pages/chat/components/chat-composer-keydown.ts
@@ -1,7 +1,11 @@
import type { ChatSendShortcut } from "../../../app/settings.ts";
import { steerableQueuedMessage } from "../chat-queue.ts";
import { restoreHistoryCaret } from "./chat-composer-dom.ts";
-import { handleSkillMenuKeydown, type SkillMenuHost } from "./chat-composer-skill-menu.ts";
+import {
+ handleSkillMenuKeydown,
+ handleSkillTokenKeydown,
+ type SkillMenuHost,
+} from "./chat-composer-skill-menu.ts";
import { handleSlashMenuKeydown, type SlashMenuHost } from "./chat-composer-slash-menu.ts";
import type { ChatComposerProps, ChatComposerState } from "./chat-composer-types.ts";
@@ -43,6 +47,10 @@ export function createComposerKeyDownHandler({
return;
}
+ if (handleSkillTokenKeydown(event)) {
+ return;
+ }
+
if (props.connected && handleSkillMenuKeydown(event, state, skillMenuHost, requestUpdate)) {
return;
}
diff --git a/ui/src/pages/chat/components/chat-composer-plus-menu.ts b/ui/src/pages/chat/components/chat-composer-plus-menu.ts
index 8b533aaeb102..31dae0297956 100644
--- a/ui/src/pages/chat/components/chat-composer-plus-menu.ts
+++ b/ui/src/pages/chat/components/chat-composer-plus-menu.ts
@@ -126,11 +126,14 @@ function renderCapabilityToggleRow(options: {
title: string | null | undefined;
icon?: TemplateResult;
note?: TemplateResult | typeof nothing;
+ checkbox?: boolean;
}) {
return html`
`;
}
@@ -211,17 +216,15 @@ function renderRootView(props: ChatComposerPlusMenuProps) {
>
-
+ ${renderCapabilityToggleRow({
+ value: "toggle-web-search",
+ label: t("chat.composer.menu.webSearch"),
+ checked: webSearchEnabled,
+ disabled: props.mutationBlockedReason !== null,
+ title: props.mutationBlockedReason,
+ icon: icons.globe,
+ checkbox: true,
+ })}
${menuDivider()}