diff --git a/.pi/extensions/pi-web.ts b/.pi/extensions/pi-web.ts index 6511181..ac7124b 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/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..1b948d9 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,243 @@ +# pi-web 开发 Handoff + +> 仓库: `D:/Workstation/pi-web` (fork from `NOirBRight/pi-web`, branch `fix/ask-question-multi-select`) +> 上游: `ygncode/pi-web` (remote `upstream`) + +## 当前状态 + +- ✅ fork 完成,remote `origin` = `NOirBRight/pi-web` +- ✅ upstream 添加完成,remote `upstream` = `ygncode/pi-web` +- ✅ npm install 完成(`--ignore-scripts` 跳过安装脚本) +- ✅ 前端测试大部分通过(242/246,4 个预存在的 storage 测试失败) +- ✅ feature branch `fix/ask-question-multi-select` 已创建 + +## 待开发问题 + +### 问题 1: AskQuestion 多选 Bug + +**优先级**: 🔴 高 +**类型**: Bug 修复 +**目标 PR 分支**: `fix/ask-question-multi-select`(已创建) + +#### 根因 + +`chat-composer-runner.js` 第 496 行: + +```js +if (questionCount === 1) { + // 单问题:点击立即发送 — 忽略了 q.multiple + sendChatMessage(...) +} +``` + +`session-entry-renderer.js` 第 137 行: + +```js +const isMulti = questions.length > 1; // 只看 question 数量,不看 q.multiple +``` + +pi 的 AskUserQuestion 工具支持单个 question 内 `multiSelect: true`,但 pi-web: +- 单 question 时点击即发送(不管 `multiple`) +- 不渲染多选 toggle 行为 + +#### 修改文件清单 + +1. **`web/src/session/render/session-entry-renderer.js`** — 渲染层 + - `isMulti` 判断加 `q.multiple` 条件 + - 给 `ask-question-block` 加 `data-multiple` 属性 + - 多选选项用 `toggle` 样式(点已有 selected 的取消),单选用 `exclusive` 样式 + - 多选选项显示 checkbox 样式标记 + +2. **`web/src/session/live/live-renderer.js`** — live 渲染层(同样逻辑) + - 同上的改动 + +3. **`web/src/session/chat/chat-composer-runner.js`** — 交互层 + - `questionCount === 1` 分支增加 `qMultiple` 检查 + - `qMultiple` 时改为 toggle selected + 显示 Submit 按钮 + - Submit 按钮对多选答案用逗号拼接格式 + +4. **`internal/ui/live_templates/session.css`** — 样式 + - 可能需要新增 `.ask-question-option.multiselect` 样式(虚线边框、多选光标) + +5. **测试文件** (对应每个改动): + - `web/src/session/render/session-entry-renderer.test.js` + - `web/src/session/chat/chat-composer-runner.test.js` + - `web/src/session/live/live-renderer.test.js` + +#### 实现细节 + +**渲染 (`session-entry-renderer.js`)**: + +```js +// 第 137 行改为: +const isMulti = questions.length > 1 || questions.some(q => q.multiple); + +// 第 164 行附近,给 block 加 data-multiple: +const qMultiple = q.multiple === true; +html += `
`; + +// 第 170 行,给选项加 multiSelect class: +const multiSelectClass = qMultiple ? ' ask-question-multiselect' : ''; +html += `<${tag} class="ask-question-option${selected ? ' selected' : ''}${actionClass}${multiSelectClass}"${dataAttrs}>`; +``` + +**交互 (`chat-composer-runner.js`)**: + +```js +// 第 496 行附近,替换 single-question 立即发送逻辑: +const qMultiple = block.dataset.multiple === 'true'; + +if (questionCount === 1 && !qMultiple) { + // 单问题单选:点击立即发送 + const question = option.dataset.question; + const answer = option.dataset.answer; + option.disabled = true; + const sent = await sendChatMessage(`"${question}" = "${answer}"`, []); + if (!sent) option.disabled = false; + return; +} + +// 多选或多问题:toggle selected + show submit +if (qMultiple) { + option.classList.toggle('selected'); +} else { + block.querySelectorAll('.ask-question-option-action').forEach(b => b.classList.remove('selected')); + option.classList.add('selected'); +} +const actions = card?.querySelector('.ask-question-actions'); +if (actions) actions.style.display = ''; +``` + +**Submit 按钮 (`chat-composer-runner.js`)** — 收集多选答案: + +```js +// 第 473 行附近的 submit 按钮逻辑: +card.querySelectorAll('.ask-question-block').forEach(block => { + const questionText = block.dataset.questionText || ''; + const qMultiple = block.dataset.multiple === 'true'; + + if (qMultiple) { + const selectedOptions = block.querySelectorAll('.ask-question-option-action.selected'); + const answers = Array.from(selectedOptions).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 || ''}"`); + } + } +}); +``` + +--- + +### 问题 3: `/` 命令列表 + +**优先级**: 🟡 低 +**类型**: 新功能 +**目标 PR 分支**: `feature/command-list`(待创建) + +#### 概述 + +pi 终端的 `/` 命令列表在 pi-web 手机界面上没有对应功能。需要: +1. 后端新增 API 获取 pi 的注册命令列表 +2. 前端新增 `/` 触发的命令面板 + +#### 修改文件清单 + +**后端(Go):** + +1. `internal/rpc/client.go` — 新增 `BuildListCommandsCommand` +2. `internal/rpc/worker.go` — 处理 `list_commands` 响应 +3. `internal/server/server.go` — 在 Register 中加 `/api/commands` 端点 +4. `internal/server/handlers.go` — 实现 `handleCommands` handler + +**前端(JS):** + +1. `web/src/session/live/command-menu.js` — 新增命令面板 UI +2. `web/src/session/chat/chat-composer-runner.js` — 输入 `/` 触发命令面板 + +#### 依赖 + +需要 pi 核心的 `pi --mode rpc` 支持 `list_commands` 命令。当前 RPC 协议只支持: +- `prompt`, `switch_session`, `set_model`, `set_thinking_level`, `abort`, `get_state` + +**方案 A**(推荐):先在 pi-web 后端做一个**本地命令映射**,硬编码 pi 的内置命令列表 + 扫描已注册扩展的命令。无需修改 pi 核心。 + +**方案 B**(长期):给 pi 核心 RPC 协议提 PR,新增 `list_commands` 命令。 + +方案 A 可以先做,后续再升级到方案 B。 + +#### 本地命令映射实现思路 + +```go +// internal/server/commands.go +var builtinCommands = []CommandInfo{ + {Name: "/compact", Description: "Compact conversation history"}, + {Name: "/clear", Description: "Clear conversation"}, + {Name: "/model", Description: "Switch model"}, + // ... +} +``` + +扫描 `~/.pi/agent/extensions/` 和 npm 扩展中的命令注册,通过静态分析或简单的协议查询获取。 + +--- + +## 问题 2 说明(非 bug) + +问题 2(/refresh 不存在)不是 pi-web bug,是本地安装问题: +- Windows 上 `install.sh` 不支持 MINGW → 二进制未自动下载 +- `.pi/extensions/` 目录缺少 `index.ts` → 已通过创建 `index.ts` 修复 + +这两个问题应该作为单独的 PR 提给上游: +1. `install.sh` Windows 支持 +2. `index.ts` 缺失修复 + +但不属于当前开发分支。 + +--- + +## 分支规划 + +``` +main (upstream/ygncode/pi-web) + │ + ├── fix/ask-question-multi-select ← 问题 1 (当前分支) + │ + ├── fix/extensions-index-entry ← 问题 2 (install.sh + index.ts) + │ + └── feature/command-list ← 问题 3 +``` + +--- + +## 验证命令 + +```bash +cd /d/Workstation/pi-web + +# 前端测试 +cd web && npx vitest run + +# 全量检查(需要 Go) +make check + +# 构建前端 +cd web && npm run build + +# 提交前 +git diff --stat +``` + +--- + +## 当前 Git 状态 + +``` +分支: fix/ask-question-multi-select +最近提交: e955baa chore: npm install (dev dependencies) +与上游同步: git fetch upstream && git rebase upstream/main +``` \ No newline at end of file diff --git a/docs/superpowers/plans/2026-05-30-ask-question-multiselect.md b/docs/superpowers/plans/2026-05-30-ask-question-multiselect.md new file mode 100644 index 0000000..9de897f --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-ask-question-multiselect.md @@ -0,0 +1,571 @@ +# AskUserQuestion Multi-Select Bug Fix — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix pi-web's AskUserQuestion rendering and interaction to correctly support `multiSelect: true` on individual questions. + +**Architecture:** The `ask_user_question` tool sends a `questions` array where each question can have `multiSelect: true`. Pi-web currently ignores this field — single questions always send immediately on click, and multi-question cards use single-select (radio) behavior. The fix propagates `multiSelect` through rendering (`session-entry-renderer.js`, `live-renderer.js`) and interaction (`chat-composer-runner.js`), adding `data-multiple` attributes, toggle behavior, and multi-select answer collection. + +**Tech Stack:** Vanilla JavaScript (no framework), Vitest for testing, JSDOM for test DOM, Go for embedded file checks. + +--- + +## File Structure + +| File | Responsibility | +|------|---------------| +| `web/src/session/render/session-entry-renderer.js` | SSR/chunk rendering: emit `data-multiple` on question blocks, add `.ask-question-multiselect` class to options | +| `web/src/session/render/session-entry-renderer.test.js` | Tests for multi-select rendering in chunked/SSR mode | +| `web/src/session/live/live-renderer.js` | Live renderer: same `data-multiple` + multiselect class changes | +| `web/src/session/live/live-renderer.test.js` | Tests for live-renderer multi-select | +| `web/src/session/chat/chat-composer-runner.js` | Click handler: respect `data-multiple` for toggle + submit button; submit handler: collect multi-select answers | +| `web/src/session/chat/chat-composer-runner.test.js` | Tests for multi-select click and submit logic | +| `internal/ui/live_templates/styles/session.css` | Add `.ask-question-multiselect` visual style (dashed border, checkbox indicator) | +| `internal/ui/ask_user_question_render_test.go` | Add Go test checks for new `data-multiple` attribute and `multiselect` class | + +--- + +## Task 1: session-entry-renderer.js — Render `data-multiple` attribute and multiselect class + +**Files:** +- Modify: `web/src/session/render/session-entry-renderer.js:137,144-191` +- Test: `web/src/session/render/session-entry-renderer.test.js` + +- [ ] **Step 1: Write the failing test** + +Add to `web/src/session/render/session-entry-renderer.test.js`: + +```js +it('renders data-multiple="true" on a question block when multiSelect is true', () => { + const r = renderer(); + const html = r.renderEntry({ + id: 'q1', + type: 'message', + message: { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'call-1', + name: 'ask_user_question', + arguments: { + questions: [{ + question: 'Pick frameworks', + multiSelect: true, + options: [ + { label: 'React' }, + { label: 'Vue' }, + { label: 'Svelte' } + ] + }] + } + }] + } + }); + expect(html).toContain('data-multiple="true"'); + expect(html).toContain('ask-question-multiselect'); +}); + +it('renders data-multiple="false" by default when multiSelect is absent', () => { + const r = renderer(); + const html = r.renderEntry({ + id: 'q1', + type: 'message', + message: { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'call-1', + name: 'ask_user_question', + arguments: { + questions: [{ question: 'Pick one', options: [{ label: 'A' }, { label: 'B' }] }] + } + }] + } + }); + expect(html).toContain('data-multiple="false"'); + expect(html).not.toContain('ask-question-multiselect'); +}); + +it('shows submit button for single question with multiSelect', () => { + const r = renderer(); + const html = r.renderEntry({ + id: 'q1', + type: 'message', + message: { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'call-1', + name: 'ask_user_question', + arguments: { + questions: [{ + question: 'Pick many', + multiSelect: true, + options: [{ label: 'A' }, { label: 'B' }] + }] + } + }] + } + }); + expect(html).toContain('ask-question-submit-btn'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd D:/Workstation/pi-web/web && npx vitest run src/session/render/session-entry-renderer.test.js` +Expected: 2 tests FAIL (no `data-multiple`, no `ask-question-multiselect` class), 1 test FAIL (no submit button for single multiSelect question) + +- [ ] **Step 3: Modify `isMulti` logic and add `data-multiple` attribute** + +In `web/src/session/render/session-entry-renderer.js`, change line 137: + +```js +// BEFORE: +const isMulti = questions.length > 1; + +// AFTER: +const isMulti = questions.length > 1 || questions.some(q => q.multiSelect); +``` + +Change the `ask-question-block` div at ~line 155 to include `data-multiple`: + +```js +// BEFORE: +html += `
`; + +// AFTER: +const qMultiple = q.multiSelect === true; +html += `
`; +``` + +Change the option rendering at ~line 170 to add `ask-question-multiselect` class when `qMultiple`: + +```js +// BEFORE: +html += `<${tag} class="ask-question-option${selected ? ' selected' : ''}${actionClass}"${dataAttrs}>`; + +// AFTER: +const multiSelectClass = qMultiple ? ' ask-question-multiselect' : ''; +html += `<${tag} class="ask-question-option${selected ? ' selected' : ''}${actionClass}${multiSelectClass}"${dataAttrs}>`; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd D:/Workstation/pi-web/web && npx vitest run src/session/render/session-entry-renderer.test.js` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +cd D:/Workstation/pi-web +git add web/src/session/render/session-entry-renderer.js web/src/session/render/session-entry-renderer.test.js +git commit -m "fix: render data-multiple and ask-question-multiselect in AskUserQuestion" +``` + +--- + +## Task 2: live-renderer.js — Same `data-multiple` and multiselect changes for live mode + +**Files:** +- Modify: `web/src/session/live/live-renderer.js:131-170` +- Test: `web/src/session/live/live-renderer.test.js` + +- [ ] **Step 1: Write the failing test** + +Add to `web/src/session/live/live-renderer.test.js`: + +```js +it('renders data-multiple="true" when multiSelect is true', () => { + const dom = new JSDOM(''); + const renderer = createLiveRenderer({ documentImpl: dom.window.document, markedImpl: marked }); + const html = renderer.renderEntry({ + id: 'q1', + type: 'message', + message: { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'call-1', + name: 'ask_user_question', + arguments: { + questions: [{ + question: 'Pick many', + multiSelect: true, + options: [{ label: 'A' }, { label: 'B' }] + }] + } + }] + } + }, []); + expect(html).toContain('data-multiple="true"'); + expect(html).toContain('ask-question-multiselect'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd D:/Workstation/pi-web/web && npx vitest run src/session/live/live-renderer.test.js` +Expected: FAIL — `data-multiple` and `ask-question-multiselect` not found + +- [ ] **Step 3: Modify live-renderer.js** + +In `web/src/session/live/live-renderer.js`, change the `qaMulti` line (~137): + +```js +// BEFORE: +var qaMulti = questions.length > 1; + +// AFTER: +var qaMulti = questions.length > 1 || questions.some(function(q) { return q.multiSelect === true; }); +``` + +Change the `ask-question-block` div to include `data-multiple`: + +```js +// BEFORE: +html += '
'; + +// AFTER: +var qMultiple = q.multiSelect === true; +html += '
'; +``` + +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