diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md
index d89f007fc..47050da0f 100644
--- a/docs/release-control/v6/internal/subsystems/ai-runtime.md
+++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md
@@ -2149,10 +2149,12 @@ draft. The referenced OpenCode source at fetched `dev` commit
with suggested actions, slash commands, and search) and keeps the footer command
entry discoverable from `packages/opencode/src/cli/cmd/run/footer.view.tsx`.
Pulse adapts that pattern for the web drawer by exposing the existing Assistant
-commands dialog from the composer chrome. The button must use the same
-`ASSISTANT_SLASH_COMMANDS` registry, `AssistantCommandHelpDialog`, and
-`executeSlashCommand` path as `/help` and `/commands`; it must not introduce a
-second command registry or a separate provider-bound prompt path.
+commands dialog from the composer chrome and making that dialog searchable and
+keyboard-selectable. The button, slash-triggered help, dialog search, and
+keyboard selection must use the same `ASSISTANT_SLASH_COMMANDS` registry,
+`filterAssistantSlashCommands`, `AssistantCommandHelpDialog`, and
+`executeSlashCommand` path as `/help` and `/commands`; they must not introduce
+a second command registry or a separate provider-bound prompt path.
Primary nav moved to governed platform/runtime destinations on 2026-05-16 and
was clarified on 2026-05-25 through `frontend-modern/src/App.tsx` and
diff --git a/frontend-modern/src/components/AI/Chat/AssistantCommandHelpDialog.tsx b/frontend-modern/src/components/AI/Chat/AssistantCommandHelpDialog.tsx
index 5f3592e54..ca57d0a0a 100644
--- a/frontend-modern/src/components/AI/Chat/AssistantCommandHelpDialog.tsx
+++ b/frontend-modern/src/components/AI/Chat/AssistantCommandHelpDialog.tsx
@@ -1,13 +1,16 @@
-import { For, Show, createEffect, onCleanup } from 'solid-js';
+import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import XIcon from 'lucide-solid/icons/x';
import {
- ASSISTANT_SLASH_COMMANDS,
+ filterAssistantSlashCommands,
getAssistantSlashCommandTokens,
type AssistantSlashCommand,
} from './assistantSlashCommands';
import { AssistantSlashCommandIcon } from './SlashCommandAutocomplete';
import {
AI_CHAT_COMMAND_HELP_CLOSE_LABEL,
+ AI_CHAT_COMMAND_HELP_EMPTY_STATE,
+ AI_CHAT_COMMAND_HELP_SEARCH_LABEL,
+ AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER,
AI_CHAT_COMMAND_HELP_TITLE,
} from '@/utils/aiChatPresentation';
@@ -17,16 +20,67 @@ interface AssistantCommandHelpDialogProps {
}
export function AssistantCommandHelpDialog(props: AssistantCommandHelpDialogProps) {
+ const [commandSearchQuery, setCommandSearchQuery] = createSignal('');
+ const [selectedCommandIndex, setSelectedCommandIndex] = createSignal(0);
+ let searchInputRef: HTMLInputElement | undefined;
+
+ const commands = createMemo(() => filterAssistantSlashCommands(commandSearchQuery()));
+ const selectedCommand = () => commands()[selectedCommandIndex()];
+
const consumeDialogCloseKey = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
};
+ const moveSelectedCommand = (direction: -1 | 1) => {
+ const count = commands().length;
+ if (count === 0) {
+ setSelectedCommandIndex(0);
+ return;
+ }
+
+ setSelectedCommandIndex((index) => Math.max(0, Math.min(count - 1, index + direction)));
+ };
+
const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key !== 'Escape') return;
- consumeDialogCloseKey(event);
- props.onClose();
+ const controlOnly = event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
+
+ switch (event.key) {
+ case 'Escape':
+ consumeDialogCloseKey(event);
+ props.onClose();
+ break;
+ case 'ArrowDown':
+ consumeDialogCloseKey(event);
+ moveSelectedCommand(1);
+ break;
+ case 'ArrowUp':
+ consumeDialogCloseKey(event);
+ moveSelectedCommand(-1);
+ break;
+ case 'Home':
+ consumeDialogCloseKey(event);
+ setSelectedCommandIndex(0);
+ break;
+ case 'End':
+ consumeDialogCloseKey(event);
+ setSelectedCommandIndex(Math.max(0, commands().length - 1));
+ break;
+ case 'Enter': {
+ consumeDialogCloseKey(event);
+ const command = selectedCommand();
+ if (!command) return;
+ props.onRunCommand(command);
+ break;
+ }
+ default:
+ if (controlOnly && event.key.toLowerCase() === 'u') {
+ consumeDialogCloseKey(event);
+ setCommandSearchQuery('');
+ }
+ break;
+ }
};
createEffect(() => {
@@ -34,6 +88,22 @@ export function AssistantCommandHelpDialog(props: AssistantCommandHelpDialogProp
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
});
+ createEffect(() => {
+ commandSearchQuery();
+ setSelectedCommandIndex(0);
+ });
+
+ createEffect(() => {
+ const count = commands().length;
+ if (selectedCommandIndex() >= count) {
+ setSelectedCommandIndex(Math.max(0, count - 1));
+ }
+ });
+
+ onMount(() => {
+ queueMicrotask(() => searchInputRef?.focus());
+ });
+
return (
-
-
- {(command) => {
- const aliases = () => getAssistantSlashCommandTokens(command).slice(1);
- return (
-
diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx
index e7b3bd83a..ff5b9c05b 100644
--- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx
+++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx
@@ -1761,10 +1761,10 @@ describe('AIChat', () => {
fireEvent.keyDown(textarea, { key: 'Enter' });
expect(screen.getByRole('dialog', { name: 'Assistant commands' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /\/models/ })).toHaveTextContent(
+ expect(screen.getByRole('option', { name: /\/models/ })).toHaveTextContent(
'Choose or set the model route (/model provider:model-id)',
);
- expect(screen.getByRole('button', { name: /\/status/ })).toHaveTextContent(
+ expect(screen.getByRole('option', { name: /\/status/ })).toHaveTextContent(
'Check the selected model route',
);
expect(mockChat.sendMessage).not.toHaveBeenCalled();
@@ -1778,7 +1778,7 @@ describe('AIChat', () => {
fireEvent.keyDown(textarea, { key: 'Enter' });
expect(screen.getByRole('dialog', { name: 'Assistant commands' })).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: /\/models/ }));
+ fireEvent.click(screen.getByRole('option', { name: /\/models/ }));
await waitFor(() => {
expect(
diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AssistantCommandHelpDialog.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AssistantCommandHelpDialog.test.tsx
index 5d5ec5de0..5d5f7b26a 100644
--- a/frontend-modern/src/components/AI/Chat/__tests__/AssistantCommandHelpDialog.test.tsx
+++ b/frontend-modern/src/components/AI/Chat/__tests__/AssistantCommandHelpDialog.test.tsx
@@ -1,10 +1,53 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { cleanup, render, screen } from '@solidjs/testing-library';
+import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { AssistantCommandHelpDialog } from '../AssistantCommandHelpDialog';
afterEach(cleanup);
describe('AssistantCommandHelpDialog', () => {
+ it('focuses command search and filters commands through the shared slash matcher', async () => {
+ render(() => );
+
+ const search = screen.getByLabelText('Search Assistant commands');
+ await waitFor(() => expect(document.activeElement).toBe(search));
+
+ fireEvent.input(search, { target: { value: 'runtime' } });
+
+ expect(screen.getByRole('listbox', { name: 'Assistant commands' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: /\/status/ })).toBeInTheDocument();
+ expect(screen.queryByRole('option', { name: /\/models/ })).not.toBeInTheDocument();
+ });
+
+ it('runs the selected filtered command with Enter', () => {
+ const onRunCommand = vi.fn();
+ render(() => );
+
+ fireEvent.input(screen.getByLabelText('Search Assistant commands'), {
+ target: { value: 'runtime' },
+ });
+ fireEvent.keyDown(document, { key: 'Enter' });
+
+ expect(onRunCommand).toHaveBeenCalledWith(
+ expect.objectContaining({ action: 'status', name: 'status' }),
+ );
+ });
+
+ it('moves command selection with arrow keys before running', () => {
+ const onRunCommand = vi.fn();
+ render(() => );
+
+ const newCommand = screen.getByRole('option', { name: /\/new/ });
+ expect(newCommand).toHaveAttribute('aria-selected', 'false');
+
+ fireEvent.keyDown(document, { key: 'ArrowDown' });
+ expect(newCommand).toHaveAttribute('aria-selected', 'true');
+
+ fireEvent.keyDown(document, { key: 'Enter' });
+ expect(onRunCommand).toHaveBeenCalledWith(
+ expect.objectContaining({ action: 'new', name: 'new' }),
+ );
+ });
+
it('consumes Escape as a local dialog close command', () => {
const onClose = vi.fn();
render(() => );
diff --git a/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts b/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts
index b7341b442..c23a861b1 100644
--- a/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts
+++ b/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts
@@ -6,6 +6,9 @@ import {
AI_CHAT_CLOSE_LABEL,
AI_CHAT_COMMAND_HELP_BUTTON_LABEL,
AI_CHAT_COMMAND_HELP_CLOSE_LABEL,
+ AI_CHAT_COMMAND_HELP_EMPTY_STATE,
+ AI_CHAT_COMMAND_HELP_SEARCH_LABEL,
+ AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER,
AI_CHAT_COMMAND_HELP_TITLE,
AI_CHAT_CONTROL_MODE_LABEL,
AI_CHAT_CONTROL_MODE_MENU_LABEL,
@@ -99,6 +102,9 @@ describe('aiChatPresentation', () => {
expect(AI_CHAT_COMMAND_HELP_TITLE).toBe('Assistant commands');
expect(AI_CHAT_COMMAND_HELP_BUTTON_LABEL).toBe('Open Assistant commands');
expect(AI_CHAT_COMMAND_HELP_CLOSE_LABEL).toBe('Close Assistant commands');
+ expect(AI_CHAT_COMMAND_HELP_SEARCH_LABEL).toBe('Search Assistant commands');
+ expect(AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER).toBe('Search commands...');
+ expect(AI_CHAT_COMMAND_HELP_EMPTY_STATE).toBe('No commands match your search');
expect(AI_CHAT_SESSION_EMPTY_STATE).toBe('No previous assistant sessions');
expect(AI_CHAT_SESSION_LOADING_STATE).toBe('Loading assistant sessions...');
expect(AI_CHAT_SESSION_SEARCH_PLACEHOLDER).toBe('Search sessions...');
diff --git a/frontend-modern/src/utils/aiChatPresentation.ts b/frontend-modern/src/utils/aiChatPresentation.ts
index b871744df..2d8c5ec64 100644
--- a/frontend-modern/src/utils/aiChatPresentation.ts
+++ b/frontend-modern/src/utils/aiChatPresentation.ts
@@ -48,6 +48,9 @@ export const AI_CHAT_SWITCH_TO_APPROVAL_LABEL = 'Switch Assistant control mode t
export const AI_CHAT_COMMAND_HELP_TITLE = 'Assistant commands';
export const AI_CHAT_COMMAND_HELP_BUTTON_LABEL = 'Open Assistant commands';
export const AI_CHAT_COMMAND_HELP_CLOSE_LABEL = 'Close Assistant commands';
+export const AI_CHAT_COMMAND_HELP_SEARCH_LABEL = 'Search Assistant commands';
+export const AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER = 'Search commands...';
+export const AI_CHAT_COMMAND_HELP_EMPTY_STATE = 'No commands match your search';
export const AI_CHAT_SESSION_EMPTY_STATE = 'No previous assistant sessions';
export const AI_CHAT_SESSION_LOADING_STATE = 'Loading assistant sessions...';
export const AI_CHAT_SESSION_SEARCH_PLACEHOLDER = 'Search sessions...';
diff --git a/internal/ai/tools/tools_discovery.go b/internal/ai/tools/tools_discovery.go
index f4a73ccdb..1de4867a7 100644
--- a/internal/ai/tools/tools_discovery.go
+++ b/internal/ai/tools/tools_discovery.go
@@ -524,6 +524,31 @@ func buildDiscoveryToolResponse(req discoveryResourceRequest, discovery *Resourc
response["ports"] = ports
}
+ // Add Docker bind mounts if present. A container path like /config is
+ // meaningless for editing or backing up persistent files without its host
+ // source, so surface the host->container mapping so the model can act on the
+ // real files, not the ephemeral container view.
+ if len(discovery.BindMounts) > 0 {
+ bindMounts := make([]map[string]interface{}, 0, len(discovery.BindMounts))
+ for _, m := range discovery.BindMounts {
+ mount := map[string]interface{}{
+ "source": m.Source,
+ "destination": m.Destination,
+ }
+ if m.ContainerName != "" {
+ mount["container_name"] = m.ContainerName
+ }
+ if m.Type != "" {
+ mount["type"] = m.Type
+ }
+ if m.ReadOnly {
+ mount["read_only"] = m.ReadOnly
+ }
+ bindMounts = append(bindMounts, mount)
+ }
+ response["bind_mounts"] = bindMounts
+ }
+
return response
}
diff --git a/internal/ai/tools/tools_discovery_test.go b/internal/ai/tools/tools_discovery_test.go
index 3dc9d8447..8d40d8f8d 100644
--- a/internal/ai/tools/tools_discovery_test.go
+++ b/internal/ai/tools/tools_discovery_test.go
@@ -358,6 +358,98 @@ func TestExecuteGetDiscovery_CanonicalAppContainerUsesDockerProviderType(t *test
assert.Equal(t, "http://192.0.2.10:8080", payload["suggested_url"])
}
+func TestBuildDiscoveryToolResponse_IncludesBindMounts(t *testing.T) {
+ req := discoveryResourceRequest{
+ resourceType: "app-container",
+ resourceID: "abc123",
+ targetID: "agent-1",
+ }
+ discovery := &ResourceDiscoveryInfo{
+ ID: "docker:agent-1:abc123",
+ ResourceType: "docker",
+ ResourceID: "abc123",
+ TargetID: "agent-1",
+ Hostname: "docker-host-1",
+ ServiceType: "homeassistant",
+ BindMounts: []DiscoveryMount{
+ {
+ ContainerName: "homeassistant",
+ Source: "/opt/appdata/homeassistant",
+ Destination: "/config",
+ Type: "bind",
+ ReadOnly: true,
+ },
+ },
+ }
+
+ resp := buildDiscoveryToolResponse(req, discovery)
+
+ rawMounts, ok := resp["bind_mounts"].([]map[string]interface{})
+ if !ok {
+ t.Fatalf("expected bind_mounts to be []map[string]interface{}, got %T", resp["bind_mounts"])
+ }
+ if assert.Len(t, rawMounts, 1) {
+ mount := rawMounts[0]
+ assert.Equal(t, "/opt/appdata/homeassistant", mount["source"])
+ assert.Equal(t, "/config", mount["destination"])
+ assert.Equal(t, "homeassistant", mount["container_name"])
+ assert.Equal(t, "bind", mount["type"])
+ assert.Equal(t, true, mount["read_only"])
+ }
+}
+
+func TestBuildDiscoveryToolResponse_OmitsBindMountsWhenEmpty(t *testing.T) {
+ req := discoveryResourceRequest{
+ resourceType: "vm",
+ resourceID: "101",
+ targetID: "node1",
+ }
+ discovery := &ResourceDiscoveryInfo{
+ ID: "vm:node1:101",
+ ResourceType: "vm",
+ ResourceID: "101",
+ TargetID: "node1",
+ Hostname: "vm-101",
+ }
+
+ resp := buildDiscoveryToolResponse(req, discovery)
+ assert.NotContains(t, resp, "bind_mounts")
+}
+
+func TestBuildDiscoveryToolResponse_OmitsOptionalMountFields(t *testing.T) {
+ req := discoveryResourceRequest{
+ resourceType: "app-container",
+ resourceID: "abc123",
+ targetID: "agent-1",
+ }
+ discovery := &ResourceDiscoveryInfo{
+ ID: "docker:agent-1:abc123",
+ ResourceType: "docker",
+ ResourceID: "abc123",
+ TargetID: "agent-1",
+ BindMounts: []DiscoveryMount{
+ {
+ Source: "/srv/data",
+ Destination: "/data",
+ },
+ },
+ }
+
+ resp := buildDiscoveryToolResponse(req, discovery)
+ rawMounts, ok := resp["bind_mounts"].([]map[string]interface{})
+ if !ok {
+ t.Fatalf("expected bind_mounts to be []map[string]interface{}, got %T", resp["bind_mounts"])
+ }
+ if assert.Len(t, rawMounts, 1) {
+ mount := rawMounts[0]
+ assert.Equal(t, "/srv/data", mount["source"])
+ assert.Equal(t, "/data", mount["destination"])
+ assert.NotContains(t, mount, "container_name")
+ assert.NotContains(t, mount, "type")
+ assert.NotContains(t, mount, "read_only")
+ }
+}
+
func TestExecuteRunDiscovery_ForcesFreshDiscovery(t *testing.T) {
provider := &stubDiscoveryProvider{
triggerResp: &ResourceDiscoveryInfo{
diff --git a/internal/servicediscovery/formatters.go b/internal/servicediscovery/formatters.go
index eae7a3669..c77d3298b 100644
--- a/internal/servicediscovery/formatters.go
+++ b/internal/servicediscovery/formatters.go
@@ -352,6 +352,17 @@ func FormatForRemediation(d *ResourceDiscovery) string {
sb.WriteString("\n")
}
+ // Data directories — where persistent data lives (backup targets, disk-full
+ // triage, restore points). FormatForAIContext surfaces these; remediation
+ // must too.
+ if len(d.DataPaths) > 0 {
+ sb.WriteString("### Data Directories\n")
+ for _, p := range d.DataPaths {
+ sb.WriteString(fmt.Sprintf("- `%s`\n", p))
+ }
+ sb.WriteString("\n")
+ }
+
// Bind mounts — edit or back up persistent files at the HOST source; the
// container only sees the destination path, so without this a fix written
// "to /config" would not survive a container recreate.
diff --git a/internal/servicediscovery/formatters_test.go b/internal/servicediscovery/formatters_test.go
index fd7b43c79..ebb52e681 100644
--- a/internal/servicediscovery/formatters_test.go
+++ b/internal/servicediscovery/formatters_test.go
@@ -223,6 +223,7 @@ func TestFormatForRemediationSurfacesControlAndMounts(t *testing.T) {
ServiceName: "Home Assistant",
CLIAccess: "docker exec homeassistant bash",
ConfigPaths: []string{"/config/automations.yaml"},
+ DataPaths: []string{"/config/.storage"},
DockerMounts: []DockerBindMount{
{ContainerName: "homeassistant", Source: "/opt/ha/config", Destination: "/config", Type: "bind"},
},
@@ -238,6 +239,7 @@ func TestFormatForRemediationSurfacesControlAndMounts(t *testing.T) {
"docker restart homeassistant", // service-control fact
"/opt/ha/config", // host bind-mount source
"/config/automations.yaml", // config file to edit
+ "/config/.storage", // data directory (backup/restore target)
} {
if !strings.Contains(out, want) {
t.Errorf("remediation context missing %q\n--- output ---\n%s", want, out)