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)