improve(ui): quiet slash command menu hierarchy (#122237)

* improve(ui): quiet slash command menu hierarchy

* improve(ui): compact slash command menu

* improve(ui): polish slash command rows

* improve(ui): streamline slash command sheet

* chore(ui): refresh command copy baseline

* fix(ui): keep skill selection in view

* test(ui): type skill menu viewport stub
This commit is contained in:
Vyctor H. Brzezowski
2026-08-11 23:00:58 -03:00
committed by GitHub
parent b529217878
commit 7630d060f8
10 changed files with 321 additions and 284 deletions
-42
View File
@@ -449,48 +449,6 @@
"path": "ui/src/pages/channels/view.nostr.ts",
"text": "NIP-05"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-composer-skill-menu.ts",
"text": "Enter"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-composer-skill-menu.ts",
"text": "Esc"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-composer-skill-menu.ts",
"text": "Tab"
},
{
"count": 2,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-composer-slash-menu.ts",
"text": "Enter"
},
{
"count": 2,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-composer-slash-menu.ts",
"text": "Esc"
},
{
"count": 2,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-composer-slash-menu.ts",
"text": "Tab"
},
{
"count": 2,
"kind": "html-text",
-8
View File
@@ -4789,15 +4789,7 @@ export const en: TranslationMap = {
commands: {
arguments: "Command arguments",
menu: "Slash commands",
instant: "instant",
optionCount: "{count} options",
showMoreOne: "Show 1 more command",
showMoreMany: "Show {count} more commands",
navigate: "navigate",
fill: "fill",
run: "run",
select: "select",
close: "close",
clearDescription: "Clear chat history",
redirectDescription: "Abort and restart with a new message",
steerDescription: "Inject a message into the active run",
-5
View File
@@ -531,11 +531,6 @@ export function getSkillCommandCompletions(filter: string): SlashCommandDef[] {
.toSorted((left, right) => left.name.localeCompare(right.name));
}
/** Count of commands hidden by tier filtering (for "Show N more" UI). */
export function getHiddenCommandCount(): number {
return SLASH_COMMANDS.filter((cmd) => (cmd.tier ?? "standard") === "power").length;
}
type ParsedSlashCommand = {
command: SlashCommandDef;
args: string;
@@ -3078,20 +3078,20 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
it("scrolls the keyboard-active slash option into view in short landscape", async () => {
const initiallyHidden = await page.evaluate(() => {
const menu = document.querySelector<HTMLElement>(".slash-menu");
const scrollRegion = document.querySelector<HTMLElement>(".slash-menu__scroll");
const options = Array.from(
document.querySelectorAll<HTMLElement>(".slash-menu-item[role='option']"),
);
const hiddenOption = options.find((option) => {
const menuRect = menu?.getBoundingClientRect();
const menuRect = scrollRegion?.getBoundingClientRect();
const optionRect = option.getBoundingClientRect();
return Boolean(menuRect && optionRect.bottom > menuRect.bottom + 1);
});
if (!menu || !hiddenOption) {
if (!scrollRegion || !hiddenOption) {
throw new Error("Expected an initially hidden slash option");
}
menu.scrollTop = 0;
const menuRect = menu.getBoundingClientRect();
scrollRegion.scrollTop = 0;
const menuRect = scrollRegion.getBoundingClientRect();
const itemRect = hiddenOption.getBoundingClientRect();
return {
id: hiddenOption.id,
@@ -3112,11 +3112,11 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
}, initiallyHidden.id);
await page.waitForFunction((expectedId) => {
const active = document.getElementById(expectedId);
const menu = active?.closest<HTMLElement>(".slash-menu");
if (!active || !menu) {
const scrollRegion = active?.closest<HTMLElement>(".slash-menu__scroll");
if (!active || !scrollRegion) {
return false;
}
const menuRect = menu.getBoundingClientRect();
const menuRect = scrollRegion.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
return activeRect.top >= menuRect.top - 1 && activeRect.bottom <= menuRect.bottom + 1;
}, initiallyHidden.id);
@@ -3125,17 +3125,17 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
const input = document.querySelector<HTMLTextAreaElement>(
".agent-chat__composer-combobox > textarea",
);
const menu = document.querySelector<HTMLElement>(".slash-menu");
const scrollRegion = document.querySelector<HTMLElement>(".slash-menu__scroll");
const active = document.querySelector<HTMLElement>(".slash-menu-item--active");
if (!input || !menu || !active) {
if (!input || !scrollRegion || !active) {
throw new Error("Expected active slash option after keyboard navigation");
}
const menuRect = menu.getBoundingClientRect();
const menuRect = scrollRegion.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
return {
activeDescendant: input.getAttribute("aria-activedescendant"),
focusedTag: document.activeElement?.tagName,
scrollTop: menu.scrollTop,
scrollTop: scrollRegion.scrollTop,
visible: activeRect.top >= menuRect.top - 1 && activeRect.bottom <= menuRect.bottom + 1,
};
});
@@ -3147,6 +3147,52 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
});
});
it("keeps overflowing skill suggestions on the nested scroll viewport", async () => {
const page = await openBrowserPage(568, 320);
try {
const items = Array.from({ length: 16 }, (_, index) => {
const active = index === 15 ? " slash-menu-item--active" : "";
return `<div class="slash-menu-item${active}" role="option">
<span class="slash-menu-leading">
<span class="slash-menu-icon">${iconSvg()}</span>
<span class="slash-menu-name">$skill_${index + 1}</span>
</span>
</div>`;
}).join("");
await page.setContent(`<!doctype html><html><head><style>${readUiCss()}</style></head><body>
<div class="slash-menu skill-menu" role="listbox">
<div class="slash-menu__scroll">${items}</div>
</div>
</body></html>`);
const result = await page.evaluate(() => {
const active = document.querySelector<HTMLElement>(".slash-menu-item--active");
const scrollRegion = active?.closest<HTMLElement>(".slash-menu__scroll");
if (!active || !scrollRegion) {
throw new Error("Expected an active skill inside the nested viewport");
}
const viewport = scrollRegion.getBoundingClientRect();
const option = active.getBoundingClientRect();
scrollRegion.scrollTop += option.bottom - viewport.bottom;
const settledOption = active.getBoundingClientRect();
const settledViewport = scrollRegion.getBoundingClientRect();
return {
outerScrollTop: active.closest<HTMLElement>(".skill-menu")?.scrollTop,
scrollTop: scrollRegion.scrollTop,
visible:
settledOption.top >= settledViewport.top - 1 &&
settledOption.bottom <= settledViewport.bottom + 1,
};
});
expect(result.outerScrollTop).toBe(0);
expect(result.scrollTop).toBeGreaterThan(0);
expect(result.visible).toBe(true);
} finally {
await closeBrowserPage(page);
}
});
it("uses the compact mobile grid when the agent filter is not rendered", async () => {
const page = await openFixture(320, 568, { singleAgent: true });
try {
+100
View File
@@ -3686,6 +3686,65 @@ describe("chat slash menu accessibility", () => {
expect(container.querySelector(".skill-menu")).toBeNull();
});
it("scrolls the keyboard-active skill inside the nested menu viewport", () => {
replaceSkillCommands(
...Array.from({ length: 8 }, (_, index) => ({
key: `skill_${index + 1}`,
description: `Skill ${index + 1}.`,
})),
);
const animationFrames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
animationFrames.push(callback);
return animationFrames.length;
});
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function (this: HTMLElement) {
const height = 28;
let top = 0;
if (this.classList.contains("slash-menu-item")) {
const scrollRegion = this.closest<HTMLElement>(".slash-menu__scroll");
const options = Array.from(
scrollRegion?.querySelectorAll<HTMLElement>(".slash-menu-item") ?? [],
);
top = options.indexOf(this) * height - (scrollRegion?.scrollTop ?? 0);
}
const bottom = this.classList.contains("slash-menu__scroll") ? height * 2 : top + height;
return {
bottom,
height: bottom - top,
left: 0,
right: 240,
top,
width: 240,
x: 0,
y: top,
toJSON: () => ({}),
};
},
);
const { container } = createReactiveDraftHarness();
document.body.append(container);
inputDraftAtEnd(container, "Use $");
animationFrames.length = 0;
for (let index = 0; index < 4; index += 1) {
keydownComposer(container, "ArrowDown");
}
animationFrames.at(-1)?.(0);
const scrollRegion = container.querySelector<HTMLElement>(".skill-menu .slash-menu__scroll");
const outerMenu = container.querySelector<HTMLElement>(".skill-menu");
const activeOption = container.querySelector<HTMLElement>(".slash-menu-item--active");
const viewportBounds = scrollRegion?.getBoundingClientRect();
const optionBounds = activeOption?.getBoundingClientRect();
expect(scrollRegion?.scrollTop).toBeGreaterThan(0);
expect(outerMenu?.scrollTop).toBe(0);
expect(optionBounds?.top).toBeGreaterThanOrEqual(viewportBounds?.top ?? 0);
expect(optionBounds?.bottom).toBeLessThanOrEqual(viewportBounds?.bottom ?? 0);
container.remove();
});
it("does not reopen a dismissed skill picker after a slow refresh", async () => {
replaceSkillCommands({ key: "prose", description: "Prose skill." });
const refresh = createDeferred();
@@ -3985,6 +4044,47 @@ describe("chat slash menu accessibility", () => {
expect(listbox?.querySelector(`#${activeId}`)?.getAttribute("role")).toBe("option");
});
it("removes instant implementation badges without hiding option counts", () => {
const harness = createSlashRerenderHarness();
const container = harness.inputAndRender(harness.container, "/");
const stopOption = Array.from(
container.querySelectorAll<HTMLElement>(".slash-menu [role='option']"),
).find((option) => option.querySelector(".slash-menu-name")?.textContent?.trim() === "/stop");
expect(stopOption).toBeDefined();
expect(stopOption?.querySelector(".slash-menu-badge")).toBeNull();
expect(container.querySelector(".slash-menu-badge")).not.toBeNull();
});
it("shows every command directly without an expander or keyboard footer", () => {
replaceSlashCommands([
{
key: "standard-command",
name: "standard-command",
description: "Standard command.",
tier: "standard",
category: "session",
},
{
key: "power-command",
name: "power-command",
description: "Power command.",
tier: "power",
category: "tools",
},
]);
const harness = createSlashRerenderHarness();
const container = harness.inputAndRender(harness.container, "/");
expect(
Array.from(container.querySelectorAll<HTMLElement>(".slash-menu [role='option']")).map(
(option) => option.querySelector(".slash-menu-name")?.textContent?.trim(),
),
).toEqual(["/standard-command", "/power-command"]);
expect(container.querySelector(".slash-menu-show-more")).toBeNull();
expect(container.querySelector(".slash-menu-footer")).toBeNull();
});
it("keeps filtered command DOM and keyboard order aligned with relevance", () => {
replaceSlashCommands([
{
@@ -188,16 +188,16 @@ export function scrollActiveSkillMenuOptionIntoView(
}
requestAnimationFrame(() => {
const activeOption = document.getElementById(activeId);
const menu = activeOption?.closest<HTMLElement>(".skill-menu");
if (!activeOption || !menu) {
const scrollRegion = activeOption?.closest<HTMLElement>(".slash-menu__scroll");
if (!activeOption || !scrollRegion) {
return;
}
const menuBounds = menu.getBoundingClientRect();
const menuBounds = scrollRegion.getBoundingClientRect();
const optionBounds = activeOption.getBoundingClientRect();
if (optionBounds.top < menuBounds.top) {
menu.scrollTop -= menuBounds.top - optionBounds.top;
scrollRegion.scrollTop -= menuBounds.top - optionBounds.top;
} else if (optionBounds.bottom > menuBounds.bottom) {
menu.scrollTop += optionBounds.bottom - menuBounds.bottom;
scrollRegion.scrollTop += optionBounds.bottom - menuBounds.bottom;
}
});
}
@@ -254,38 +254,40 @@ export function renderSkillMenu(
role="listbox"
aria-label=${t("chat.skills.menu")}
>
${state.skillCommandRefreshPending || state.skillMenuItems.length === 0
? html`<div class="slash-menu-group">
<div class="slash-menu-group__label">${t("chat.skills.loading")}</div>
</div>`
: html`<div class="slash-menu-group">
<div class="slash-menu-group__label">${t("chat.skills.label")}</div>
${state.skillMenuItems.map(
(command, index) => html`
<div
id=${skillOptionId(props.paneId, command)}
class="slash-menu-item ${index === state.skillMenuIndex
? "slash-menu-item--active"
: ""}"
role="option"
aria-selected=${index === state.skillMenuIndex}
@mousedown=${(event: MouseEvent) => event.preventDefault()}
@click=${() => selectSkillMention(command, props, requestUpdate)}
@mouseenter=${() => {
state.skillMenuIndex = index;
requestUpdate();
}}
>
<span class="slash-menu-icon">${icons.zap}</span>
<span class="slash-menu-name">$${command.name}</span>
<span class="slash-menu-desc">${getSlashCommandDescription(command)}</span>
</div>
`,
)}
</div>`}
<div class="slash-menu-footer">
<kbd></kbd> ${t("chat.commands.navigate")} <kbd>Tab</kbd> ${t("chat.commands.fill")}
<kbd>Enter</kbd> ${t("chat.commands.select")} <kbd>Esc</kbd> ${t("chat.commands.close")}
<div class="slash-menu__scroll">
${state.skillCommandRefreshPending || state.skillMenuItems.length === 0
? html`<div class="slash-menu-group">
<div class="slash-menu-group__label">${t("chat.skills.loading")}</div>
</div>`
: html`<div class="slash-menu-group">
<div class="slash-menu-group__label">${t("chat.skills.label")}</div>
${state.skillMenuItems.map(
(command, index) => html`
<div
id=${skillOptionId(props.paneId, command)}
class="slash-menu-item ${index === state.skillMenuIndex
? "slash-menu-item--active"
: ""}"
role="option"
aria-selected=${index === state.skillMenuIndex}
@mousedown=${(event: MouseEvent) => event.preventDefault()}
@click=${() => selectSkillMention(command, props, requestUpdate)}
@mouseenter=${() => {
state.skillMenuIndex = index;
requestUpdate();
}}
>
<span class="slash-menu-leading">
<span class="slash-menu-icon">${icons.zap}</span>
<span class="slash-menu-name">$${command.name}</span>
</span>
<span class="slash-menu-trailing">
<span class="slash-menu-desc">${getSlashCommandDescription(command)}</span>
</span>
</div>
`,
)}
</div>`}
</div>
</div>
`;
@@ -3,7 +3,6 @@ import { icons, type IconName } from "../../../components/icons.ts";
import { t } from "../../../i18n/index.ts";
import {
SLASH_COMMANDS,
getHiddenCommandCount,
getSlashCommandCategoryLabel,
getSlashCommandCompletions,
getSlashCommandDescription,
@@ -19,7 +18,6 @@ export function resetSlashMenuState(state: ChatComposerState): void {
state.slashMenuCommand = null;
state.slashMenuArgItems = [];
state.slashMenuItems = [];
state.slashMenuExpanded = false;
}
function hasVisibleSlashMenuState(state: ChatComposerState): boolean {
@@ -28,8 +26,7 @@ function hasVisibleSlashMenuState(state: ChatComposerState): boolean {
state.slashMenuMode !== "command" ||
state.slashMenuCommand !== null ||
state.slashMenuArgItems.length > 0 ||
state.slashMenuItems.length > 0 ||
state.slashMenuExpanded
state.slashMenuItems.length > 0
);
}
@@ -112,9 +109,7 @@ export function updateSlashMenu(
if (!opts.skipSlashIntent) {
requestSlashCommandRefresh(value, props, requestUpdate, getCurrentValue);
}
const items = getSlashCommandCompletions(match[1] ?? "", {
showAll: state.slashMenuExpanded,
});
const items = getSlashCommandCompletions(match[1] ?? "", { showAll: true });
state.slashMenuItems = items;
state.slashMenuOpen = items.length > 0;
state.slashMenuIndex = 0;
@@ -274,18 +269,18 @@ export function scrollActiveSlashMenuOptionIntoView(
}
requestAnimationFrame(() => {
const activeOption = document.getElementById(activeId);
const menu = activeOption?.closest<HTMLElement>(".slash-menu");
if (!activeOption || !menu) {
const scrollRegion = activeOption?.closest<HTMLElement>(".slash-menu__scroll");
if (!activeOption || !scrollRegion) {
return;
}
const menuBounds = menu.getBoundingClientRect();
const menuBounds = scrollRegion.getBoundingClientRect();
const optionBounds = activeOption.getBoundingClientRect();
// scrollIntoView also moves the short-landscape composer and page. Keep
// keyboard navigation owned by the menu so textarea focus stays stable.
if (optionBounds.top < menuBounds.top) {
menu.scrollTop -= menuBounds.top - optionBounds.top;
scrollRegion.scrollTop -= menuBounds.top - optionBounds.top;
} else if (optionBounds.bottom > menuBounds.bottom) {
menu.scrollTop += optionBounds.bottom - menuBounds.bottom;
scrollRegion.scrollTop += optionBounds.bottom - menuBounds.bottom;
}
});
}
@@ -321,40 +316,41 @@ export function renderSlashMenu(
role="listbox"
aria-label=${t("chat.commands.arguments")}
>
<div class="slash-menu-group">
<div class="slash-menu-group__label">
/${state.slashMenuCommand.name} ${getSlashCommandDescription(state.slashMenuCommand)}
<div class="slash-menu__scroll">
<div class="slash-menu-group">
<div class="slash-menu-group__label">
/${state.slashMenuCommand.name} ${getSlashCommandDescription(state.slashMenuCommand)}
</div>
${state.slashMenuArgItems.map(
(arg, i) => html`
<div
id=${getSlashArgOptionId(props.paneId, state.slashMenuCommand?.name ?? "", arg)}
class="slash-menu-item ${i === state.slashMenuIndex
? "slash-menu-item--active"
: ""}"
role="option"
aria-selected=${i === state.slashMenuIndex}
@click=${() => selectSlashArg(arg, props, requestUpdate, true)}
@mouseenter=${() => {
state.slashMenuIndex = i;
requestUpdate();
}}
>
<span class="slash-menu-leading">
<span class="slash-menu-icon"
>${state.slashMenuCommand?.icon
? renderSlashIcon(state.slashMenuCommand.icon)
: nothing}</span
>
<span class="slash-menu-name">${arg}</span>
</span>
<span class="slash-menu-trailing">
<span class="slash-menu-desc">/${state.slashMenuCommand?.name} ${arg}</span>
</span>
</div>
`,
)}
</div>
${state.slashMenuArgItems.map(
(arg, i) => html`
<div
id=${getSlashArgOptionId(props.paneId, state.slashMenuCommand?.name ?? "", arg)}
class="slash-menu-item ${i === state.slashMenuIndex
? "slash-menu-item--active"
: ""}"
role="option"
aria-selected=${i === state.slashMenuIndex}
@click=${() => selectSlashArg(arg, props, requestUpdate, true)}
@mouseenter=${() => {
state.slashMenuIndex = i;
requestUpdate();
}}
>
${state.slashMenuCommand?.icon
? html`<span class="slash-menu-icon"
>${renderSlashIcon(state.slashMenuCommand.icon)}</span
>`
: nothing}
<span class="slash-menu-name">${arg}</span>
<span class="slash-menu-desc">/${state.slashMenuCommand?.name} ${arg}</span>
</div>
`,
)}
</div>
<div class="slash-menu-footer">
<kbd></kbd> ${t("chat.commands.navigate")} <kbd>Tab</kbd> ${t("chat.commands.fill")}
<kbd>Enter</kbd> ${t("chat.commands.run")} <kbd>Esc</kbd>
${t("chat.commands.close")}
</div>
</div>
`;
@@ -396,21 +392,23 @@ export function renderSlashMenu(
requestUpdate();
}}
>
${cmd.icon
? html`<span class="slash-menu-icon">${renderSlashIcon(cmd.icon)}</span>`
: nothing}
<span class="slash-menu-name">/${cmd.name}</span>
${cmd.args ? html`<span class="slash-menu-args">${cmd.args}</span>` : nothing}
<span class="slash-menu-desc">${getSlashCommandDescription(cmd)}</span>
${cmd.argOptions?.length
? html`<span class="slash-menu-badge"
>${t("chat.commands.optionCount", {
count: String(cmd.argOptions.length),
})}</span
>`
: cmd.executeLocal && !cmd.args
? html` <span class="slash-menu-badge">${t("chat.commands.instant")}</span> `
<span class="slash-menu-leading">
<span class="slash-menu-icon"
>${cmd.icon ? renderSlashIcon(cmd.icon) : nothing}</span
>
<span class="slash-menu-name">/${cmd.name}</span>
${cmd.args ? html`<span class="slash-menu-args">${cmd.args}</span>` : nothing}
</span>
<span class="slash-menu-trailing">
<span class="slash-menu-desc">${getSlashCommandDescription(cmd)}</span>
${cmd.argOptions?.length
? html`<span class="slash-menu-badge"
>${t("chat.commands.optionCount", {
count: String(cmd.argOptions.length),
})}</span
>`
: nothing}
</span>
</div>
`,
)}
@@ -418,31 +416,9 @@ export function renderSlashMenu(
`,
);
const hiddenCount = state.slashMenuExpanded ? 0 : getHiddenCommandCount();
return html`
<div id=${listboxId} class="slash-menu" role="listbox" aria-label=${t("chat.commands.menu")}>
${sections}
${hiddenCount > 0
? html`<button
class="slash-menu-show-more"
@click=${(event: Event) => {
event.preventDefault();
event.stopPropagation();
state.slashMenuExpanded = true;
updateSlashMenu(draft, requestUpdate, props);
}}
>
${hiddenCount === 1
? t("chat.commands.showMoreOne")
: t("chat.commands.showMoreMany", { count: String(hiddenCount) })}
</button>`
: nothing}
<div class="slash-menu-footer">
<kbd></kbd> ${t("chat.commands.navigate")} <kbd>Tab</kbd> ${t("chat.commands.fill")}
<kbd>Enter</kbd> ${t("chat.commands.select")} <kbd>Esc</kbd>
${t("chat.commands.close")}
</div>
<div class="slash-menu__scroll">${sections}</div>
</div>
`;
}
@@ -15,7 +15,6 @@ function createChatComposerState(): ChatComposerState {
slashMenuMode: "command",
slashMenuCommand: null,
slashMenuArgItems: [],
slashMenuExpanded: false,
slashCommandRefreshPending: false,
skillMenuOpen: false,
skillMenuItems: [],
@@ -164,7 +164,6 @@ export type ChatComposerState = {
slashMenuMode: "command" | "args";
slashMenuCommand: SlashCommandDef | null;
slashMenuArgItems: string[];
slashMenuExpanded: boolean;
slashCommandRefreshPending: boolean;
skillMenuOpen: boolean;
skillMenuItems: SlashCommandDef[];
+66 -96
View File
@@ -3817,64 +3817,89 @@ button.chat-reply-preview--message:disabled {
bottom: 100%;
left: 0;
right: 0;
max-height: 320px;
overflow-y: auto;
max-height: 288px;
overflow: hidden;
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
border-radius: var(--menu-radius);
box-shadow: var(--shadow-md);
z-index: 30;
margin-bottom: 4px;
}
.slash-menu__scroll {
box-sizing: border-box;
max-height: inherit;
overflow-y: auto;
padding: var(--menu-padding);
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--muted) 35%, transparent) transparent;
}
.slash-menu__scroll::-webkit-scrollbar-track,
.slash-menu__scroll::-webkit-scrollbar-corner {
background: transparent;
}
.slash-menu-group + .slash-menu-group {
margin-top: 4px;
margin-top: 2px;
padding-top: 4px;
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
}
.slash-menu-group__label {
padding: 4px 10px 2px;
font-size: 0.68rem;
font-weight: 700;
padding: 4px 8px 3px;
font-size: calc(10px * var(--control-ui-text-scale));
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
opacity: 0.7;
letter-spacing: 0.08em;
color: var(--muted);
}
.slash-menu-item {
display: flex;
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: var(--radius-sm);
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease;
column-gap: 16px;
min-height: var(--menu-item-height);
padding: 0 8px;
border-radius: var(--menu-item-radius);
transition: background var(--duration-fast) ease;
}
.slash-menu-item:hover,
.slash-menu-item--active {
background: color-mix(in srgb, var(--accent) 10%, var(--bg-hover));
background: color-mix(in srgb, var(--bg-hover) 52%, transparent);
}
.slash-menu-leading,
.slash-menu-trailing {
display: flex;
align-items: center;
min-width: 0;
}
.slash-menu-leading {
gap: 8px;
}
.slash-menu-trailing {
justify-content: flex-end;
gap: 8px;
}
.slash-menu-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--accent);
opacity: 0.7;
color: var(--muted);
}
.slash-menu-icon svg {
width: 14px;
height: 14px;
width: 13px;
height: 13px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
@@ -3882,100 +3907,45 @@ button.chat-reply-preview--message:disabled {
stroke-linejoin: round;
}
.slash-menu-item--active .slash-menu-icon,
.slash-menu-item:hover .slash-menu-icon {
opacity: 1;
}
.slash-menu-name {
font-size: 0.82rem;
font-weight: 600;
font-family: var(--mono);
color: var(--accent);
flex-shrink: 0;
max-width: calc(100% - 24px);
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--control-ui-text-sm);
font-weight: 500;
color: var(--text);
white-space: nowrap;
}
.slash-menu-args {
font-size: 0.75rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--control-ui-text-xs);
/* Full-opacity --muted keeps slash-arg hints at WCAG AA on dark surfaces. */
color: var(--muted);
font-family: var(--mono);
white-space: nowrap;
}
.slash-menu-desc {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
font-size: 0.75rem;
font-size: var(--control-ui-text-sm);
color: var(--muted);
}
.slash-menu-item--active .slash-menu-name {
color: var(--accent-hover);
}
.slash-menu-item--active .slash-menu-desc {
color: var(--text);
}
.slash-menu-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 1px 6px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
font-size: var(--control-ui-text-xs);
font-weight: 500;
color: var(--muted);
white-space: nowrap;
flex-shrink: 0;
}
.slash-menu-show-more {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
padding: 8px 10px;
margin-top: 4px;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent);
background: none;
border: none;
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.slash-menu-show-more:hover {
background: color-mix(in srgb, var(--accent) 8%, transparent);
color: var(--accent-hover);
}
.slash-menu-footer {
display: flex;
gap: 10px;
padding: 6px 10px 4px;
font-size: 0.68rem;
color: var(--muted);
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
margin-top: 4px;
}
.slash-menu-footer kbd {
display: inline-block;
padding: 1px 4px;
font-size: 0.65rem;
font-family: var(--mono);
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
line-height: 1.3;
}
.chat-attachments-preview {
display: flex;
gap: 8px;