From 7e71055a8e30923cfa35f388609af878dbe855ac Mon Sep 17 00:00:00 2001 From: Paul Qu Date: Sat, 30 May 2026 22:51:18 +0800 Subject: [PATCH] feat: add slash command palette to chat composer - /api/commands endpoint serves command list (static defaults + dynamic from ~/.pi/agent/pi-web/commands.json) - Command palette JS module: shows suggestions when typing /, filters as you type, arrow key navigation, Enter/Tab to select, Escape to dismiss - Portal-based positioning: palette appended to document.body with fixed positioning to escape parent stacking contexts - CSS themed with var(--container-bg), var(--dim), var(--accent) - pi-web extension writes commands.json on load (with 3s delay for other extensions to register) and refreshes every 5 minutes - 29 default commands including /compact, /clear, /model, /gsd-* Co-authored-by: noirbright --- .pi/extensions/pi-web.ts | 20 ++ internal/server/commands.go | 82 ++++++++ internal/server/commands_test.go | 103 ++++++++++ internal/server/server.go | 1 + internal/ui/live_templates/styles/session.css | 50 +++++ web/src/session/chat/chat-composer-runner.js | 17 ++ web/src/session/chat/command-palette.js | 184 ++++++++++++++++++ web/src/session/chat/command-palette.test.js | 86 ++++++++ 8 files changed, 543 insertions(+) create mode 100644 internal/server/commands.go create mode 100644 internal/server/commands_test.go create mode 100644 web/src/session/chat/command-palette.js create mode 100644 web/src/session/chat/command-palette.test.js diff --git a/.pi/extensions/pi-web.ts b/.pi/extensions/pi-web.ts index 4dd1e75..0fad816 100644 --- a/.pi/extensions/pi-web.ts +++ b/.pi/extensions/pi-web.ts @@ -842,6 +842,26 @@ export default function (pi: ExtensionAPI) { // Keep startup quiet; /remote and /refresh show actionable errors if needed. }); + // Write commands list for pi-web server to serve + // Delay 3s to allow other extensions to register their commands first + const writeCommands = () => { + try { + const allCommands = pi.getCommands(); + const commandsJson = Array.from(allCommands.values()).map((cmd) => ({ + name: cmd.name.startsWith('/') ? cmd.name : `/${cmd.name}`, + description: cmd.description || '', + })); + const piWebDir = `${agentDir()}/pi-web`; + mkdirSync(piWebDir, { recursive: true }); + writeFileSync(`${piWebDir}/commands.json`, JSON.stringify(commandsJson, null, 2)); + } catch { + // Best effort — command palette falls back to defaults + } + }; + setTimeout(writeCommands, 3000); + // Refresh every 5 minutes to pick up new commands + setInterval(writeCommands, 5 * 60 * 1000); + // ── /pi-web ─────────────────────────────────────────────────────── pi.registerCommand("pi-web", { description: "Manage pi-web: status, token, start, stop, restart, remote, update", diff --git a/internal/server/commands.go b/internal/server/commands.go new file mode 100644 index 0000000..ae3c144 --- /dev/null +++ b/internal/server/commands.go @@ -0,0 +1,82 @@ +package server + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "sync" +) + +var defaultCommands = []map[string]string{ + {"name": "/compact", "description": "Compact conversation history"}, + {"name": "/clear", "description": "Clear conversation"}, + {"name": "/model", "description": "Switch model"}, + {"name": "/thinking", "description": "Change thinking level"}, + {"name": "/web", "description": "Open current session in browser"}, + {"name": "/refresh", "description": "Sync web-written messages back into session"}, + {"name": "/remote", "description": "Show QR code for remote access"}, + {"name": "/pi-web", "description": "Manage pi-web: status, token, start, stop"}, +} + +type commandsCache struct { + mu sync.Mutex + commands []map[string]string + modTime int64 +} + +var cmdCache commandsCache + +func (s *Server) handleCommands(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + commands := s.loadCommands() + writeJSON(w, 0, map[string]any{"commands": commands}) +} + +func (s *Server) loadCommands() []map[string]string { + cmdCache.mu.Lock() + defer cmdCache.mu.Unlock() + + // Try to read commands.json from pi config directory + configDir := os.Getenv("PI_CONFIG_DIR") + if configDir == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return defaultCommands + } + configDir = filepath.Join(homeDir, ".pi", "agent") + } + commandsFile := filepath.Join(configDir, "pi-web", "commands.json") + + info, err := os.Stat(commandsFile) + if err != nil { + // File doesn't exist — return defaults + return defaultCommands + } + + // Use cache if file hasn't changed + if cmdCache.commands != nil && cmdCache.modTime == info.ModTime().UnixNano() { + return cmdCache.commands + } + + data, err := os.ReadFile(commandsFile) + if err != nil { + return defaultCommands + } + + var commands []map[string]string + if err := json.Unmarshal(data, &commands); err != nil { + return defaultCommands + } + + if len(commands) == 0 { + return defaultCommands + } + + cmdCache.commands = commands + cmdCache.modTime = info.ModTime().UnixNano() + return commands +} \ No newline at end of file diff --git a/internal/server/commands_test.go b/internal/server/commands_test.go new file mode 100644 index 0000000..93c8793 --- /dev/null +++ b/internal/server/commands_test.go @@ -0,0 +1,103 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestHandleCommands(t *testing.T) { + s := &Server{} + req := httptest.NewRequest(http.MethodGet, "/api/commands", nil) + w := httptest.NewRecorder() + s.handleCommands(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var result map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + commands, ok := result["commands"].([]any) + if !ok || len(commands) == 0 { + t.Fatal("expected non-empty commands array") + } + for _, cmd := range commands { + m, ok := cmd.(map[string]any) + if !ok { + t.Fatal("expected command to be a map") + } + if _, ok := m["name"]; !ok { + t.Fatal("expected command to have name field") + } + if _, ok := m["description"]; !ok { + t.Fatal("expected command to have description field") + } + } +} + +func TestHandleCommandsRejectsPost(t *testing.T) { + s := &Server{} + req := httptest.NewRequest(http.MethodPost, "/api/commands", nil) + w := httptest.NewRecorder() + s.handleCommands(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", w.Code) + } +} + +func TestLoadCommandsFromFile(t *testing.T) { + // Create temp commands file + tmpDir := t.TempDir() + piWebDir := filepath.Join(tmpDir, "pi-web") + if err := os.MkdirAll(piWebDir, 0755); err != nil { + t.Fatal(err) + } + commandsFile := filepath.Join(piWebDir, "commands.json") + customCommands := []map[string]string{ + {"name": "/custom", "description": "Custom command"}, + } + data, _ := json.Marshal(customCommands) + if err := os.WriteFile(commandsFile, data, 0644); err != nil { + t.Fatal(err) + } + + // Set PI_CONFIG_DIR to temp dir + origConfigDir := os.Getenv("PI_CONFIG_DIR") + os.Setenv("PI_CONFIG_DIR", tmpDir) + defer os.Setenv("PI_CONFIG_DIR", origConfigDir) + + // Clear cache + cmdCache.commands = nil + cmdCache.modTime = 0 + + s := &Server{} + commands := s.loadCommands() + if len(commands) != 1 { + t.Fatalf("expected 1 command, got %d", len(commands)) + } + if commands[0]["name"] != "/custom" { + t.Fatalf("expected /custom, got %s", commands[0]["name"]) + } +} + +func TestLoadCommandsFallsBackToDefaults(t *testing.T) { + // Set PI_CONFIG_DIR to non-existent + tmpDir := t.TempDir() + origConfigDir := os.Getenv("PI_CONFIG_DIR") + os.Setenv("PI_CONFIG_DIR", filepath.Join(tmpDir, "nonexistent")) + defer os.Setenv("PI_CONFIG_DIR", origConfigDir) + + // Clear cache + cmdCache.commands = nil + cmdCache.modTime = 0 + + s := &Server{} + commands := s.loadCommands() + if len(commands) != len(defaultCommands) { + t.Fatalf("expected %d default commands, got %d", len(defaultCommands), len(commands)) + } +} \ No newline at end of file diff --git a/internal/server/server.go b/internal/server/server.go index e1fe8ab..7fb899d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -139,6 +139,7 @@ func (s *Server) Register(mux *http.ServeMux) { mux.HandleFunc("/api/clone-session", s.auth.Wrap(s.handleApiCloneSession)) mux.HandleFunc("/api/rename-session", s.auth.Wrap(s.handleRenameSession)) mux.HandleFunc("/api/recent-locations", s.auth.Wrap(s.handleRecentLocations)) + mux.HandleFunc("/api/commands", s.auth.Wrap(s.handleCommands)) if s.push != nil { s.push.Register(mux, s.auth.Wrap) } diff --git a/internal/ui/live_templates/styles/session.css b/internal/ui/live_templates/styles/session.css index 5fd27d7..9fa8fd2 100644 --- a/internal/ui/live_templates/styles/session.css +++ b/internal/ui/live_templates/styles/session.css @@ -2745,3 +2745,53 @@ .pi-sheet-panel.pi-sheet-mobile .fork-palette-footer { display: none; } + + .pi-command-suggestions { + position: fixed; + max-height: 250px; + overflow-y: auto; + background: var(--container-bg, #1a1a2e); + border: 1px solid var(--dim, #333); + border-radius: 8px; + box-shadow: 0 -12px 32px rgba(0,0,0,0.5); + z-index: 99999; + display: none; + pointer-events: auto; + } + + .pi-command-suggestion-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + cursor: pointer; + font-size: 12px; + border-bottom: 1px solid color-mix(in srgb, var(--dim) 30%, transparent); + } + + .pi-command-suggestion-item:last-child { + border-bottom: none; + } + + .pi-command-suggestion-item:hover, + .pi-command-suggestion-item.selected { + background: color-mix(in srgb, var(--accent, #6366f1) 20%, var(--container-bg, #1a1a2e)); + } + + .pi-command-suggestion-name { + font-weight: bold; + color: var(--text, #fff); + } + + .pi-command-suggestion-desc { + color: var(--muted, #888); + font-size: 11px; + margin-left: 12px; + text-align: right; + } + + @media (max-height: 600px) { + .pi-command-suggestions { + max-height: 160px; + } + } diff --git a/web/src/session/chat/chat-composer-runner.js b/web/src/session/chat/chat-composer-runner.js index b9d6931..8748806 100644 --- a/web/src/session/chat/chat-composer-runner.js +++ b/web/src/session/chat/chat-composer-runner.js @@ -1,3 +1,5 @@ +import { setupCommandPalette } from './command-palette.js'; + export function runChatComposer({ documentImpl = document, windowImpl = window, @@ -584,14 +586,29 @@ export function runChatComposer({ return true; } + function setupCommandPaletteForSession() { + const chatEl = documentImpl.getElementById('pi-chat-message'); + if (!chatEl) return null; + const sessionId = new URLSearchParams(windowImpl.location.search).get('id') || ''; + return setupCommandPalette({ + chatInput: chatEl, + documentImpl, + windowImpl, + fetchImpl: __piChatApi?.fetch || (typeof fetch !== 'undefined' ? fetch : undefined), + sessionId, + }); + } + let _modelSelectorApi = null; let _thinkingSelectorApi = null; + let _commandPaletteApi = null; function initPiChatControls() { setupCwdCopy(); if (!setupPiChatComposer()) return; _modelSelectorApi = loadModelSelector(); _thinkingSelectorApi = setupThinkingLevelSelector(); + _commandPaletteApi = setupCommandPaletteForSession(); } if (document.readyState === 'loading') { diff --git a/web/src/session/chat/command-palette.js b/web/src/session/chat/command-palette.js new file mode 100644 index 0000000..123eefd --- /dev/null +++ b/web/src/session/chat/command-palette.js @@ -0,0 +1,184 @@ +const COMMANDS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +export function setupCommandPalette({ + chatInput, + documentImpl = document, + windowImpl = window, + fetchImpl = fetch, + sessionId = '', +} = {}) { + if (!chatInput) return null; + + let commands = []; + let commandsLoadedAt = 0; + let palette = null; + let selectedIndex = -1; + let visible = false; + + let commandsLoading = null; + + function loadCommands() { + if (commands.length > 0 && Date.now() - commandsLoadedAt < COMMANDS_CACHE_TTL) return Promise.resolve(); + if (commandsLoading) return commandsLoading; + + commandsLoading = (async () => { + try { + const url = sessionId ? `/api/commands?id=${encodeURIComponent(sessionId)}` : '/api/commands'; + const res = await fetchImpl(url); + if (!res.ok) return; + const data = await res.json(); + commands = data.commands || []; + commandsLoadedAt = Date.now(); + } catch (_) { + // Silently fail + } finally { + commandsLoading = null; + } + })(); + return commandsLoading; + } + + function escapeHtml(text) { + return String(text).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function createPalette() { + if (palette) return palette; + palette = documentImpl.createElement('div'); + palette.className = 'pi-command-suggestions'; + palette.setAttribute('role', 'listbox'); + documentImpl.body.appendChild(palette); // Move to body to escape parent stacking context + return palette; + } + + function updatePosition() { + if (!palette || !chatInput) return; + const rect = chatInput.getBoundingClientRect(); + const win = documentImpl.defaultView || windowImpl; + // Position fixed to overlay everything + palette.style.position = 'fixed'; + palette.style.left = rect.left + 'px'; + palette.style.width = rect.width + 'px'; + const bottomGap = (win.innerHeight - rect.top) + 8; + palette.style.bottom = bottomGap + 'px'; + palette.style.top = 'auto'; // Ensure it grows upwards + } + + function removePalette() { + if (palette && palette.parentNode) { + palette.parentNode.removeChild(palette); + } + palette = null; + visible = false; + selectedIndex = -1; + } + + function updateSelection(p) { + const items = p.querySelectorAll('.pi-command-suggestion-item'); + items.forEach((item, i) => { + item.classList.toggle('selected', i === selectedIndex); + }); + } + + function showPalette(filter = '') { + const p = createPalette(); + const filtered = filter + ? commands.filter(c => c.name.toLowerCase().includes(filter.toLowerCase()) || c.description.toLowerCase().includes(filter.toLowerCase())) + : commands; + + if (filtered.length === 0) { + hidePalette(); + return; + } + + p.innerHTML = ''; + filtered.forEach((cmd, i) => { + const item = documentImpl.createElement('div'); + item.className = 'pi-command-suggestion-item' + (i === 0 ? ' selected' : ''); + item.setAttribute('role', 'option'); + item.innerHTML = '' + escapeHtml(cmd.name) + '' + escapeHtml(cmd.description) + ''; + item.addEventListener('mousedown', (e) => { + e.preventDefault(); + selectCommand(cmd); + }); + p.appendChild(item); + }); + + selectedIndex = 0; + p.style.display = 'block'; + visible = true; + updatePosition(); // Recalculate position + } + + function hidePalette() { + if (palette) { + palette.style.display = 'none'; + } + visible = false; + selectedIndex = -1; + } + + function selectCommand(cmd) { + chatInput.value = cmd.name + ' '; + chatInput.focus(); + chatInput.dispatchEvent(new (documentImpl.defaultView || windowImpl).Event('input', { bubbles: true })); + hidePalette(); + } + + async function handleInput() { + const value = chatInput.value || ''; + if (value === '/') { + await loadCommands(); + showPalette(''); + } else if (value.startsWith('/')) { + const filter = value.slice(1); + showPalette(filter); + } else { + hidePalette(); + } + } + + function handleKeydown(e) { + if (!visible || !palette) return; + + const items = palette.querySelectorAll('.pi-command-suggestion-item'); + if (items.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + selectedIndex = Math.min(selectedIndex + 1, items.length - 1); + updateSelection(palette); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + selectedIndex = Math.max(selectedIndex - 1, 0); + updateSelection(palette); + } else if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < items.length) { + const name = items[selectedIndex].querySelector('.pi-command-suggestion-name').textContent; + const cmd = commands.find(c => c.name === name) || { name }; + selectCommand(cmd); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + hidePalette(); + } + } + + // Load commands eagerly so the palette is ready + loadCommands(); + + chatInput.addEventListener('input', handleInput); + chatInput.addEventListener('keydown', handleKeydown); + chatInput.addEventListener('blur', () => { + // Small delay to allow click events on palette items + setTimeout(hidePalette, 150); + }); + chatInput.addEventListener('focus', () => { + if (chatInput.value.startsWith('/')) { + handleInput(); + } + }); + + return { hidePalette, loadCommands, removePalette }; +} \ No newline at end of file diff --git a/web/src/session/chat/command-palette.test.js b/web/src/session/chat/command-palette.test.js new file mode 100644 index 0000000..14eb1b5 --- /dev/null +++ b/web/src/session/chat/command-palette.test.js @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { setupCommandPalette } from './command-palette.js'; + +function makeEnv() { + const dom = new JSDOM('
', { url: 'https://example.test' }); + const chatInput = dom.window.document.getElementById('chat'); + const commands = [ + { name: '/compact', description: 'Compact conversation history' }, + { name: '/clear', description: 'Clear conversation' }, + { name: '/model', description: 'Switch model' }, + ]; + const fetchImpl = vi.fn(() => { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ commands }), + }); + }); + return { dom, chatInput, commands, fetchImpl }; +} + +describe('command palette', () => { + it('returns null without chatInput', () => { + const result = setupCommandPalette({}); + expect(result).toBeNull(); + }); + + it('fetches commands eagerly on setup', async () => { + const { dom, chatInput, fetchImpl } = makeEnv(); + setupCommandPalette({ chatInput, documentImpl: dom.window.document, windowImpl: dom.window, fetchImpl, sessionId: 'test-session' }); + // Commands are loaded eagerly + await new Promise(r => setTimeout(r, 50)); + expect(fetchImpl).toHaveBeenCalled(); + }); + + it('shows palette when typing / and filters on subsequent input', async () => { + const { dom, chatInput, fetchImpl } = makeEnv(); + setupCommandPalette({ chatInput, documentImpl: dom.window.document, windowImpl: dom.window, fetchImpl, sessionId: 's1' }); + // Wait for eager load + await new Promise(r => setTimeout(r, 50)); + // Type / to show palette + chatInput.value = '/'; + chatInput.dispatchEvent(new dom.window.Event('input', { bubbles: true })); + await new Promise(r => setTimeout(r, 10)); + // Palette is appended to body, query from document + const palette = dom.window.document.querySelector('.pi-command-suggestions'); + expect(palette).toBeTruthy(); + // Filter by typing /com + chatInput.value = '/com'; + chatInput.dispatchEvent(new dom.window.Event('input', { bubbles: true })); + await new Promise(r => setTimeout(r, 0)); + const items = palette.querySelectorAll('.pi-command-suggestion-item'); + // /compact matches "com" + expect(items.length).toBe(1); + expect(items[0].textContent).toContain('/compact'); + }); + + it('hides palette when input does not start with /', async () => { + const { dom, chatInput, fetchImpl } = makeEnv(); + setupCommandPalette({ chatInput, documentImpl: dom.window.document, windowImpl: dom.window, fetchImpl, sessionId: 's1' }); + await new Promise(r => setTimeout(r, 50)); + chatInput.value = '/'; + chatInput.dispatchEvent(new dom.window.Event('input', { bubbles: true })); + await new Promise(r => setTimeout(r, 10)); + const palette = dom.window.document.querySelector('.pi-command-suggestions'); + expect(palette).toBeTruthy(); + chatInput.value = 'hello'; + chatInput.dispatchEvent(new dom.window.Event('input', { bubbles: true })); + expect(palette.style.display).toBe('none'); + }); + + it('selects command on click and fills chat input', async () => { + const { dom, chatInput, fetchImpl } = makeEnv(); + setupCommandPalette({ chatInput, documentImpl: dom.window.document, windowImpl: dom.window, fetchImpl, sessionId: 's1' }); + await new Promise(r => setTimeout(r, 50)); + chatInput.value = '/'; + chatInput.dispatchEvent(new dom.window.Event('input', { bubbles: true })); + await new Promise(r => setTimeout(r, 10)); + const item = dom.window.document.querySelector('.pi-command-suggestion-item'); + expect(item).toBeTruthy(); + // Use mousedown instead of click (palette uses mousedown to prevent blur) + item.dispatchEvent(new dom.window.Event('mousedown', { bubbles: true })); + await new Promise(r => setTimeout(r, 0)); + expect(chatInput.value).toBe('/compact '); + }); +}); \ No newline at end of file