';
+```
+
+Change the option class to include `ask-question-multiselect`:
+
+```js
+// BEFORE:
+var cls = 'ask-question-option'+(sel?' selected':'')+(qaInteractive?' ask-question-option-action':'');
+
+// AFTER:
+var multiCls = qMultiple ? ' ask-question-multiselect' : '';
+var cls = 'ask-question-option'+(sel?' selected':'')+(qaInteractive?' ask-question-option-action':'')+multiCls;
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd D:/Workstation/pi-web/web && npx vitest run src/session/live/live-renderer.test.js`
+Expected: All tests PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd D:/Workstation/pi-web
+git add web/src/session/live/live-renderer.js web/src/session/live/live-renderer.test.js
+git commit -m "fix: render data-multiple and multiselect class in live renderer"
+```
+
+---
+
+## Task 3: chat-composer-runner.js — Multi-select click and submit logic
+
+**Files:**
+- Modify: `web/src/session/chat/chat-composer-runner.js:496-517`
+- Test: `web/src/session/chat/chat-composer-runner.test.js`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `web/src/session/chat/chat-composer-runner.test.js`:
+
+```js
+describe('AskUserQuestion multiSelect', () => {
+ it('toggles selection on multi-select option click instead of sending immediately', () => {
+ const html = `
+
+
`;
+ const dom = new JSDOM(html, { url: 'https://example.test' });
+ const sendChatMessage = vi.fn(async () => true);
+ runChatComposer({
+ documentImpl: dom.window.document,
+ windowImpl: dom.window,
+ chatApi: {},
+ chatSelectors: { THINKING_LEVELS: [] },
+ modelSelector: {},
+ thinkingSelector: {},
+ sendChatMessage
+ });
+ dom.window.document.dispatchEvent(new dom.window.Event('DOMContentLoaded'));
+ const optA = dom.window.document.querySelector('[data-answer="A"]');
+ optA.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
+ // Should toggle, not send immediately
+ expect(sendChatMessage).not.toHaveBeenCalled();
+ expect(optA.classList.contains('selected')).toBe(true);
+ // Click again to deselect
+ optA.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
+ expect(optA.classList.contains('selected')).toBe(false);
+ });
+
+ it('collects multi-select answers with comma-separated values', () => {
+ const html = `
+
+
+
+
+
+
+
+
+
+
+
`;
+ const dom = new JSDOM(html, { url: 'https://example.test' });
+ const sendChatMessage = vi.fn(async () => true);
+ runChatComposer({
+ documentImpl: dom.window.document,
+ windowImpl: dom.window,
+ chatApi: {},
+ chatSelectors: { THINKING_LEVELS: [] },
+ modelSelector: {},
+ thinkingSelector: {},
+ sendChatMessage
+ });
+ dom.window.document.dispatchEvent(new dom.window.Event('DOMContentLoaded'));
+ const submitBtn = dom.window.document.querySelector('.ask-question-submit-btn');
+ submitBtn.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
+ // Should send comma-separated answers
+ expect(sendChatMessage).toHaveBeenCalledWith('"Pick many" = "React, Vue"', []);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd D:/Workstation/pi-web/web && npx vitest run src/session/chat/chat-composer-runner.test.js`
+Expected: FAIL — multi-select click sends immediately instead of toggling; submit doesn't collect multi-select
+
+- [ ] **Step 3: Update click handler for multi-select toggle behavior**
+
+In `web/src/session/chat/chat-composer-runner.js`, replace the single-question immediate-send block (around line 496-507):
+
+```js
+// BEFORE:
+if (questionCount === 1) {
+ // Single question: send immediately
+ const question = option.dataset.question || 'Question';
+ const answer = option.dataset.answer || option.textContent.trim();
+ option.disabled = true;
+ const sent = await sendChatMessage(`"${question}" = "${answer}"`, []);
+ if (!sent) option.disabled = false;
+ return;
+}
+
+// Multi-question: mark selection, show submit button
+if (block) {
+ block.querySelectorAll('.ask-question-option-action').forEach(b => b.classList.remove('selected'));
+ option.classList.add('selected');
+}
+
+// AFTER:
+const qMultiple = block?.dataset.multiple === 'true';
+
+if (questionCount === 1 && !qMultiple) {
+ // Single question, single select: send immediately
+ const question = option.dataset.question || 'Question';
+ const answer = option.dataset.answer || option.textContent.trim();
+ option.disabled = true;
+ const sent = await sendChatMessage(`"${question}" = "${answer}"`, []);
+ if (!sent) option.disabled = false;
+ return;
+}
+
+// Multi-question or multi-select: toggle selection
+if (qMultiple) {
+ option.classList.toggle('selected');
+} else if (block) {
+ block.querySelectorAll('.ask-question-option-action').forEach(b => b.classList.remove('selected'));
+ option.classList.add('selected');
+}
+```
+
+- [ ] **Step 4: Update submit handler for multi-select answer collection**
+
+In the submit button handler (~line 473-479), change the answer collection logic:
+
+```js
+// BEFORE:
+card.querySelectorAll('.ask-question-block').forEach(block => {
+ const questionText = block.dataset.questionText || '';
+ const sel = block.querySelector('.ask-question-option-action.selected');
+ if (sel && questionText) parts.push(`"${questionText}" = "${sel.dataset.answer || ''}"`);
+});
+
+// AFTER:
+card.querySelectorAll('.ask-question-block').forEach(block => {
+ const questionText = block.dataset.questionText || '';
+ const blockMultiple = block.dataset.multiple === 'true';
+ if (blockMultiple) {
+ const selected = block.querySelectorAll('.ask-question-option-action.selected');
+ const answers = Array.from(selected).map(sel => sel.dataset.answer || '');
+ if (answers.length > 0 && questionText) {
+ parts.push(`"${questionText}" = "${answers.join(', ')}"`);
+ }
+ } else {
+ const sel = block.querySelector('.ask-question-option-action.selected');
+ if (sel && questionText) parts.push(`"${questionText}" = "${sel.dataset.answer || ''}"`);
+ }
+});
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `cd D:/Workstation/pi-web/web && npx vitest run src/session/chat/chat-composer-runner.test.js`
+Expected: All tests PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd D:/Workstation/pi-web
+git add web/src/session/chat/chat-composer-runner.js web/src/session/chat/chat-composer-runner.test.js
+git commit -m "fix: support multiSelect toggle and comma-separated answers in AskUserQuestion"
+```
+
+---
+
+## Task 4: CSS — Add `.ask-question-multiselect` visual style
+
+**Files:**
+- Modify: `internal/ui/live_templates/styles/session.css`
+
+- [ ] **Step 1: Add CSS rule for multi-select option style**
+
+Add after the `.ask-question-option.selected` rule (~line 961) in `internal/ui/live_templates/styles/session.css`:
+
+```css
+.ask-question-option.multiselect {
+ cursor: pointer;
+ border-style: dashed;
+}
+
+.ask-question-option.multiselect.selected::before {
+ content: '☑ ';
+ color: var(--accent);
+}
+
+.ask-question-option.multiselect:not(.selected)::before {
+ content: '☐ ';
+ color: var(--dim);
+}
+```
+
+- [ ] **Step 2: Verify CSS is included in Go tests**
+
+Run: `cd D:/Workstation/pi-web && go test ./internal/ui/ -run TestAskUserQuestion -v`
+Expected: PASS — the Go test checks that `ask-question-option` exists in the embedded CSS, which still passes since we only added a new class variant.
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd D:/Workstation/pi-web
+git add internal/ui/live_templates/styles/session.css
+git commit -m "style: add multiselect visual style for AskUserQuestion options"
+```
+
+---
+
+## Task 5: Go embedded-asset test — Add `data-multiple` check
+
+**Files:**
+- Modify: `internal/ui/ask_user_question_render_test.go`
+
+- [ ] **Step 1: Add check for `data-multiple` and `multiselect` class**
+
+In `internal/ui/ask_user_question_render_test.go`, add to the `TestAskUserQuestionToolHasDedicatedRenderer` checks slice:
+
+```go
+checks := []string{
+ "case 'ask_user_question':",
+ "renderAskUserQuestionTool(args, result)",
+ "ask-question-card",
+ "ask-question-option",
+ "data-multiple",
+ "ask-question-multiselect",
+}
+```
+
+- [ ] **Step 2: Run Go test to verify**
+
+Run: `cd D:/Workstation/pi-web && go test ./internal/ui/ -run TestAskUserQuestion -v`
+Expected: PASS (the embedded JS now contains both `data-multiple` and `ask-question-multiselect`)
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd D:/Workstation/pi-web
+git add internal/ui/ask_user_question_render_test.go
+git commit -m "test: add data-multiple and multiselect class checks to Go embedded asset test"
+```
+
+---
+
+## Task 6: Manual integration test
+
+**Files:** None (manual verification)
+
+- [ ] **Step 1: Build frontend assets**
+
+Run: `cd D:/Workstation/pi-web/web && npm run build`
+Expected: Build succeeds
+
+- [ ] **Step 2: Rebuild Go binary (embeds frontend assets)**
+
+Run: `cd D:/Workstation/pi-web && go build -o pi-web.exe ./cmd/pi-web`
+Expected: Build succeeds
+
+- [ ] **Step 3: Manual test with pi — trigger a multiSelect AskUserQuestion**
+
+1. Start pi-web: `./pi-web.exe`
+2. In a pi terminal session, trigger an `ask_user_question` with `multiSelect: true`:
+
+ Use the `ask_user_question` tool with a question like:
+ ```json
+ {
+ "questions": [{
+ "question": "Which frameworks?",
+ "multiSelect": true,
+ "options": [
+ {"label": "React"},
+ {"label": "Vue"},
+ {"label": "Svelte"}
+ ]
+ }]
+ }
+ ```
+
+4. In the pi-web browser:
+ - Verify options show `☐` checkboxes (dashed border)
+ - Click an option → toggles selected with `☑` (not immediate send)
+ - Click again → deselects
+ - Multiple options can be selected
+ - Click "Send answers" → sends `"Which frameworks?" = "React, Vue"` format
+ - Single-question without multiSelect still sends immediately on click
+
+- [ ] **Step 4: Final commit if any fixes needed**
+
+```bash
+git add -A
+git commit -m "fix: integration test adjustments for AskUserQuestion multiSelect"
+```
\ No newline at end of file
diff --git a/docs/superpowers/plans/2026-05-30-slash-command-palette.md b/docs/superpowers/plans/2026-05-30-slash-command-palette.md
new file mode 100644
index 0000000..9106db5
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-30-slash-command-palette.md
@@ -0,0 +1,444 @@
+# Slash Command List — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
+
+**Goal:** Add a `/` command palette to pi-web that shows available slash commands and lets users select one to execute via chat.
+
+**Architecture:** pi-web's Go backend exposes a `/api/commands` endpoint that returns a static list of known slash commands (pi builtins + pi-web extension commands). The frontend adds a command palette UI that triggers when the user types `/` in the chat input, filtering and displaying matching commands, and inserting the selected command into the chat input field.
+
+**Tech Stack:** Go (server endpoint), vanilla JavaScript (frontend UI), CSS (styling)
+
+---
+
+## Task 1: Add `/api/commands` Go endpoint
+
+**Files:**
+- Create: `internal/server/commands.go`
+- Modify: `internal/server/server.go` (register route)
+- Test: `internal/server/commands_test.go`
+
+The endpoint returns a JSON array of command objects with `name` and `description` fields.
+
+- [ ] **Step 1: Create `internal/server/commands.go`**
+
+```go
+package server
+
+var builtinCommands = []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" },
+}
+
+func (s *Server) handleCommands(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
+ return
+ }
+ writeJSON(w, 0, map[string]any{"commands": builtinCommands})
+}
+```
+
+- [ ] **Step 2: Register route in `server.go`**
+
+Add to `Register()`:
+```go
+mux.HandleFunc("/api/commands", s.auth.Wrap(s.handleCommands))
+```
+
+- [ ] **Step 3: Write test**
+
+```go
+package server
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "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")
+ }
+}
+
+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)
+ }
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+```bash
+cd D:/Workstation/pi-web && go test ./internal/server/ -run TestHandleCommands -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/server/commands.go internal/server/commands_test.go internal/server/server.go
+git commit -m "feat: add /api/commands endpoint for slash command list"
+```
+
+---
+
+## Task 2: Add command palette JS module
+
+**Files:**
+- Create: `web/src/session/chat/command-palette.js`
+- Create: `web/src/session/chat/command-palette.test.js`
+
+The module exports `setupCommandPalette({ chatInput, documentImpl, windowImpl, fetchImpl, sessionId })` which:
+1. Listens for `/` keypress in the chat input
+2. Fetches commands from `/api/commands`
+3. Shows a filtered dropdown
+4. On selection, inserts the command text into the chat input
+
+- [ ] **Step 1: Create `web/src/session/chat/command-palette.js`**
+
+```js
+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;
+
+ async function loadCommands() {
+ if (commands.length > 0 && Date.now() - commandsLoadedAt < COMMANDS_CACHE_TTL) return;
+ 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 — command palette is optional
+ }
+ }
+
+ function createPalette() {
+ if (palette) return palette;
+ palette = documentImpl.createElement('div');
+ palette.className = 'command-palette';
+ palette.setAttribute('role', 'listbox');
+ palette.style.display = 'none';
+ chatInput.parentNode.insertBefore(palette, chatInput.nextSibling);
+ return palette;
+ }
+
+ 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 && filter) {
+ p.style.display = 'none';
+ visible = false;
+ return;
+ }
+
+ p.innerHTML = '';
+ filtered.forEach((cmd, i) => {
+ const item = documentImpl.createElement('div');
+ item.className = 'command-palette-item' + (i === selectedIndex ? ' selected' : '');
+ item.setAttribute('role', 'option');
+ item.innerHTML = `
${escapeHtml(cmd.name)}${escapeHtml(cmd.description)}`;
+ item.addEventListener('click', () => selectCommand(cmd));
+ item.addEventListener('mouseenter', () => {
+ selectedIndex = i;
+ updateSelection(p);
+ });
+ p.appendChild(item);
+ });
+
+ p.style.display = '';
+ visible = true;
+ selectedIndex = filtered.length > 0 ? 0 : -1;
+ updateSelection(p);
+ }
+
+ function hidePalette() {
+ if (palette) {
+ palette.style.display = 'none';
+ }
+ visible = false;
+ selectedIndex = -1;
+ }
+
+ function updateSelection(p) {
+ const items = p.querySelectorAll('.command-palette-item');
+ items.forEach((item, i) => {
+ item.classList.toggle('selected', i === selectedIndex);
+ });
+ }
+
+ function selectCommand(cmd) {
+ chatInput.value = cmd.name + ' ';
+ chatInput.focus();
+ hidePalette();
+ }
+
+ function escapeHtml(text) {
+ return String(text).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ }
+
+ chatInput.addEventListener('input', async () => {
+ const value = chatInput.value;
+ if (value.startsWith('/')) {
+ const filter = value.slice(1);
+ await loadCommands();
+ showPalette(filter);
+ } else {
+ hidePalette();
+ }
+ });
+
+ chatInput.addEventListener('keydown', (e) => {
+ if (!visible) return;
+ const items = palette.querySelectorAll('.command-palette-item');
+ 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) {
+ items[selectedIndex].click();
+ }
+ } else if (e.key === 'Escape') {
+ hidePalette();
+ }
+ });
+
+ documentImpl.addEventListener('click', (e) => {
+ if (visible && palette && !palette.contains(e.target) && e.target !== chatInput) {
+ hidePalette();
+ }
+ });
+
+ return { hidePalette, loadCommands };
+}
+```
+
+- [ ] **Step 2: Write test**
+
+```js
+import { describe, expect, it, vi } from 'vitest';
+import { JSDOM } from 'jsdom';
+import { setupCommandPalette } from './command-palette.js';
+
+describe('command palette', () => {
+ it('returns null without chatInput', () => {
+ const result = setupCommandPalette({});
+ expect(result).toBeNull();
+ });
+
+ it('shows palette when typing /', async () => {
+ const dom = new JSDOM('
', { url: 'https://example.test' });
+ const chatInput = dom.window.document.getElementById('chat');
+ const commands = [{ name: '/compact', description: 'Compact history' }];
+ const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ commands }), { status: 200 })));
+ setupCommandPalette({ chatInput, documentImpl: dom.window.document, windowImpl: dom.window, fetchImpl, sessionId: 's1' });
+ chatInput.value = '/';
+ chatInput.dispatchEvent(new dom.window.Event('input'));
+ await new Promise(r => setTimeout(r, 0));
+ expect(fetchImpl).toHaveBeenCalled();
+ });
+
+ it('selects command on Enter', async () => {
+ const dom = new JSDOM('
', { url: 'https://example.test' });
+ const chatInput = dom.window.document.getElementById('chat');
+ const commands = [{ name: '/compact', description: 'Compact history' }];
+ const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ commands }), { status: 200 })));
+ setupCommandPalette({ chatInput, documentImpl: dom.window.document, windowImpl: dom.window, fetchImpl, sessionId: 's1' });
+ chatInput.value = '/';
+ chatInput.dispatchEvent(new dom.window.Event('input'));
+ await new Promise(r => setTimeout(r, 0));
+ expect(chatInput.value).toBe('/');
+ });
+});
+```
+
+- [ ] **Step 3: Run test**
+
+```bash
+cd D:/Workstation/pi-web/web && npx vitest run src/session/chat/command-palette.test.js
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web/src/session/chat/command-palette.js web/src/session/chat/command-palette.test.js
+git commit -m "feat: add command palette JS module for slash command autocomplete"
+```
+
+---
+
+## Task 3: Wire command palette into chat composer
+
+**Files:**
+- Modify: `web/src/session/chat/chat-composer-runner.js`
+- Modify: `web/src/session/chat/chat-composer-runner.test.js`
+
+Import and call `setupCommandPalette` from the chat composer runner, passing the textarea element.
+
+- [ ] **Step 1: Import and call `setupCommandPalette` in chat-composer-runner.js**
+
+Add near top of file (after other imports):
+```js
+import { setupCommandPalette } from './command-palette.js';
+```
+
+In the `runChatComposer` function, after textarea is obtained, add:
+```js
+setupCommandPalette({
+ chatInput: textarea,
+ documentImpl,
+ windowImpl,
+ fetchImpl: __piChatApi.fetch || fetch,
+ sessionId: textarea.form?.dataset.sessionId || '',
+});
+```
+
+- [ ] **Step 2: Run existing tests to verify no regressions**
+
+```bash
+cd D:/Workstation/pi-web/web && npx vitest run src/session/chat/chat-composer-runner.test.js
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web/src/session/chat/chat-composer-runner.js web/src/session/chat/chat-composer-runner.test.js
+git commit -m "feat: wire command palette into chat composer"
+```
+
+---
+
+## Task 4: Add command palette CSS styling
+
+**Files:**
+- Modify: `internal/ui/live_templates/styles/session.css`
+
+Add styles for the command palette dropdown.
+
+- [ ] **Step 1: Add CSS rules**
+
+Add to the end of `session.css`:
+
+```css
+ .command-palette {
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ right: 0;
+ max-height: 200px;
+ overflow-y: auto;
+ background: var(--card-bg);
+ border: 1px solid var(--dim);
+ border-radius: 4px;
+ margin-bottom: 4px;
+ z-index: 10;
+ }
+
+ .command-palette-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 6px 10px;
+ cursor: pointer;
+ font-size: 12px;
+ }
+
+ .command-palette-item:hover,
+ .command-palette-item.selected {
+ background: color-mix(in srgb, var(--accent) 10%, var(--body-bg));
+ }
+
+ .command-palette-name {
+ font-weight: bold;
+ color: var(--text);
+ }
+
+ .command-palette-desc {
+ color: var(--muted);
+ font-size: 11px;
+ margin-left: 12px;
+ }
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add internal/ui/live_templates/styles/session.css
+git commit -m "style: add command palette dropdown styling"
+```
+
+---
+
+## Task 5: Integration test
+
+- [ ] **Step 1: Build frontend**
+
+```bash
+cd D:/Workstation/pi-web/web && npm run build
+```
+
+- [ ] **Step 2: Build Go binary**
+
+```bash
+cd D:/Workstation/pi-web && go build -o pi-web.exe ./cmd/pi-web
+```
+
+- [ ] **Step 3: Manual test**
+
+1. Start pi-web
+2. In browser, type `/` in chat input
+3. Command palette should appear
+4. Filter by typing `/com` → should show `/compact`
+5. Arrow keys navigate, Enter/Tab selects, Escape dismisses
+6. Selected command fills chat input
+
+- [ ] **Step 4: Final commit if fixes needed**
\ No newline at end of file
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 84c2ec6..958eb80 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -188,6 +188,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))
mux.HandleFunc("/api/git/info", s.auth.Wrap(s.handleGitInfo))
mux.HandleFunc("/api/git/rename-branch", s.auth.Wrap(s.handleGitRenameBranch))
mux.HandleFunc("/custom-themes.css", s.auth.Wrap(s.handleCustomThemes))
diff --git a/internal/ui/live_templates/styles/session.css b/internal/ui/live_templates/styles/session.css
index 262483f..63dc397 100644
--- a/internal/ui/live_templates/styles/session.css
+++ b/internal/ui/live_templates/styles/session.css
@@ -3588,6 +3588,56 @@
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;
+ }
+ }
+
/* Model Usage desktop custom styles matching cmd+k command-palette */
@media (min-width: 901px) {
.pi-sheet-backdrop.mu-sheet-backdrop {
@@ -4242,4 +4292,3 @@
cursor: pointer;
}
.cat-settings-skip:hover { filter: brightness(1.08); }
-
diff --git a/pi-web-linux b/pi-web-linux
new file mode 100644
index 0000000..f478ba9
Binary files /dev/null and b/pi-web-linux differ
diff --git a/web/src/session/chat/chat-composer-runner.js b/web/src/session/chat/chat-composer-runner.js
index 5dec121..70c0cd8 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,
@@ -866,8 +868,22 @@ 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();
@@ -893,6 +909,7 @@ export function runChatComposer({
_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