# 2026-08-16

## 构建 pi-web-console（Pi CLI → Web UI）

在 `/Users/sario/Desktop/pi-web-console` 从零构建了一个可运行项目，把 Pi CLI（pi.dev）全部能力通过 Web UI 暴露。

**架构（与 Pi 官方集成规范一致）**
- 后端 Node（express + ws）：每会话 spawn 一个 `pi --mode rpc` 子进程，stdin/stdout 做 JSONL 双向桥接；Web 前端经 WebSocket 与后端对话，前端直接发 Pi RPC 命令对象，后端只做透明转发 + id 关联。
- 辅以 `pi --mode json`（print/JSON 事件流）做一次性调用（oneshot）。
- 关键文件：`server/pi-rpc-session.js`（桥接核心）、`server/session-manager.js`、`server/index.js`（WS 协议路由）、`public/js/app.js`（前端，含扩展 UI 子协议 select/confirm/input/editor + setStatus/setWidget 处理）、`test/mock-pi.js`（无密钥演示）。

**关键结论/踩坑（有复用价值）**
- Pi RPC 严格 JSONL：仅以 `\n` 切分记录、剥尾部 `\r`，**不能用** Node `readline`（会把 U+2028/2029 当换行）。
- 命令响应带 `id` 关联；`bash_execution_update` 事件也带源命令 id。
- **`pi --version` 探活较慢，会阻塞端口绑定**——server.listen 必须立即执行，探活放异步（已修复）。
- 真实 pi v0.84.2（用户已装）：默认 provider `my-local-llm`、模型 `claude-opus-5`（经 agentrouter.org 代理）；`get_available_models` 返回 8 个模型。会话存 `~/.pi/agent/sessions/<编码cwd>/<时间戳>_<uuid>.jsonl`，首行为 `{"type":"session",...}`。
- 用户装了多个 pi 扩展（pi-web-access、pi-subagents、pi-lens、pi-hermes-memory、pi-mcp-adapter、powerline 等），启动时会吐 `extension_ui_request` 的 setWidget/setStatus 事件（含 ANSI 色码，前端需 stripAnsi）。

**验证**：`npm run smoke` 6/6；mock 端到端 WS 8/8；真实 pi 会话创建+模型枚举通过；静态资源全 200。

**运行**：`npm install && npm start`（http://127.0.0.1:4120）；无密钥演示 `npm run demo`。

## 前端 UI 全面重构（同日第二轮）

重写 `public/index.html` + `public/css/style.css` + `public/js/app.js` 交互层：
- 建立 CSS 设计令牌系统（`--accent/--bg/--border/--r-*/--shadow-*/--dur-*/--ease`，light/dark 双主题，`color-scheme`）。
- 布局：聊天列居中限宽 860px；模型/思考下拉移入侧栏"会话配置"区；顶栏改用内联 SVG 图标按钮（零依赖 sprite：`<symbol>`+`<use>`）。
- 响应式：≤900px 侧栏变抽屉（transform + scrim，`body.sidebar-open`）；≤520px 隐藏次要按钮；`prefers-reduced-motion`、`hover:none` 适配。
- 交互增强：流式文本光标 `.cursor`（blink）、工具卡默认展开 + max-height 折叠动画 + chevron、消息/条目进场动画、按钮 hover 上浮+active 缩放、toast 退出动画、modal 入场动画+Esc 关闭+焦点管理+aria、断线重连横幅 `#conn-banner`、会话名双击/Enter 重命名（改用模态框替代 prompt()）、theme 按钮图标随主题切换。
- 注意：CSS 用了 `color-mix()`（Chrome 111+/Safari 16.2+/FF 113+）。
- 验证：全部源文件 `node --check` 通过、smoke 6/6、静态 200。用户本机正跑 `npm run dev`（node --watch），改动即时生效；未动后端协议。

## 第三轮：能力补全（命令面板/图片/压缩/Shell）

- **命令面板**：顶栏 `#btn-commands` → `get_commands` 列出 pi 扩展命令/技能/提示模板（source 徽章 extension/skill/prompt），点击以 `/name` 经 `dispatchPrompt` 发送（Pi 自动展开）。
- **图片多模态**：composer 支持粘贴（paste 抓 clipboardData image）与上传（`#btn-attach` + 隐藏 file input），转 base64 存 `state.attachments`，随 prompt 以 `{type:'image',data,mimeType}`（ImageContent）发送；附件预览条 `.attach-bar`、用户气泡缩略图 `.bubble-imgs`；单图 ≤10MB。
- **手动压缩**：`#btn-compact` → `compact` 命令，toast 摘要。
- **Shell 直接执行**：`#btn-bash` → 弹窗发 RPC `bash` 命令（自定义 `id`），`bash_execution_update`（按 id 匹配）流式输出到 `.bash-out`，response 兜底全量输出 + exit code/截断提示；提示"输出会随下次 prompt 进 LLM 上下文"（官方行为）。
- 重构：`sendUserMessage` 拆出 `dispatchPrompt(text, images)`（running 时 streamingBehavior=steer），命令面板/图片共用。
- mock-pi 更新：bash 流式 `bash_execution_update`、`get_commands` 4 条示例；端到端验证 get_commands=4 / bash 流式 3 块+response / compact 全通过。
- 移动端 ≤520px 隐藏 bash/compact 图标按钮。

## 修复：≥900px 桌面布局错乱（响应式 bug）

**症状**：窗口宽度 ≥ 901px 时（侧边栏从抽屉切回固定布局），整个布局崩坏——
- 侧边栏只显示上半段（"活跃会话"以下全部被截），底部冒出一行竖排字符
- 聊天空状态、输入框、状态栏全部挤在视口**左下角**，宽度被压成 ~240px
- 顶栏按钮虽然基本在位但孤立悬浮

**根因**（grid 自动布局陷阱）：`#app` 的 `grid-template-columns` 只声明 2 列（`sidebar-w 1fr`），但 `<div id="sidebar-scrim">` 错误地放在 `#app` **内部**。即使 `position: fixed`，它仍是 `#app` 的子元素，会参与 grid 的自动放置：
- col 1 → `#sidebar`
- col 2 → `#sidebar-scrim`（抢占了主区位置）
- col 3（隐式 auto）→ `#main`，被内容撑成窄条

**修复**：把 `#sidebar-scrim` 移出 `#app`（成为 `body` 直接子元素，置于 `</div>` 之后），让 grid 只剩 2 个正常子元素。HTML 改动 2 行，CSS 一行未动；移动端抽屉 scrim 行为完全不受影响（`position: fixed` 自始至终脱离文档流）。

**验证**：Chrome 无头分别截 1440 / 1000 / 905 / 900 四个宽度，对比修复前后——三个桌面宽度全部恢复正常，900px 抽屉模式保持不变。

**经验**：CSS Grid 中，`position: fixed/absolute` 元素仍消耗一个 grid track；要做"蒙层/遮罩"类辅助元素，要么挪到容器外，要么用 `grid-template-areas` 显式分配，要么用 `display: none` 切流。

## 第五轮：快速提问接入命令面板 + 会话树可视化

- **快速提问入口**：命令面板（`showCommands`）顶部固定「⚡ 快速提问」项（`.cmd-quick`，badge `oneshot`）→ `showQuickPrompt()` 弹窗（textarea，Enter 运行）→ 复用全局 oneshot 流式渲染到聊天区。侧栏快速提问区支持 **Enter 提交 / Shift+Enter 换行**，运行按钮带 `.is-loading` 旋转态（`.btn.is-loading::after` spinner）。
- **会话树可视化**（`renderTreeNode` 重写 + `showTree` 增强）：
  - 角色图标 + 分色：💬 用户 / 🤖 助手 / 🔧 工具 / 🖥️ bash / 📄 其他；
  - 分支连线（`ul` 左虚线边框），节点默认展开、点击 ▾ 折叠（rotate 动画）；
  - **当前分支路径高亮**：从 `leafId` 沿 `entry.parentId` 回溯构建 `activeIds`，`.entry-label.active` accent 底色 + `.leaf` 左侧绿条；
  - 用户消息节点 hover 出现「分叉」按钮 → `fork {entryId}` 后 `refreshSessionContext`；
  - 顶部统计条（节点总数 / 当前分支 id）。
- 验证：语法全通过、smoke 6/6、dev server 4120 已提供新代码。

## 模型下拉：增加供应商信息展示

为 `#model-select` 选项追加 provider 标签，按 `name · Provider` 格式渲染，并把常见 provider 品牌化（anthropic→Anthropic、openai→OpenAI、my-local-llm→本地 LLM 等），未知 provider 原样保留；同时把 `m.provider` 写到 `option.dataset.provider` 便于后续扩展。

- 改动：`public/js/app.js` 的 `renderModelSelect()` 改为调 `modelLabel(m)`；新增 `modelLabel` / `fmtProvider` 与 `KNOWN_PROVIDERS` 常量映射。
- 验证：`node --check public/js/app.js` 通过；通过 headless Chrome + CDP 让页面跑真实 pi，`#model-select` 已渲染 8 个选项，文本示例 `MiMo-V2.5-Pro · xiaomi-token-plan-cn`、`claude-opus-5 · 本地 LLM`、`mimo-v2.5-pro · mimo-xianyu`。

**验证手段笔记**：headless Chrome + CDP (remote-debugging 9222) 可直接驱动页面交互（点击 / 读 DOM / 截图）。新版本 Chrome 要求 `/json/new` 用 PUT；用 `Page.captureScreenshot` 取 base64 写盘。复杂 headless 调试不必纠结 ego-browser，本机装 Chrome 即可。

## 第六轮：会话预览 / 树内搜索 / @文件 参数

- **历史会话首条消息预览**：后端 `SessionManager._readPreview`（替代 `_readHeader`）读 jsonl 前 128KB，解析出 session 头 + 首条有内容的 message 文本（`content` 字符串或 text blocks，user 优先）；`listOnDisk` 增加 `firstMessage` 字段。前端 `renderDiskSessions` 两行展示（`.sess-name` + `.sess-preview`），搜索也匹配 `firstMessage`。
- **树内搜索**：`showTree` 顶部加 `.tree-search` 输入框；每个树节点 `li.dataset.search`（文本小写），输入时隐藏未命中节点、并向上展开命中节点的祖先分支（沿 `node-children` 找 `has-children` 父级加 `.open`）；清空恢复默认展开。
- **快速提问支持 @文件（CLI @file 语法）**：
  - 前端 `parseAtFiles(text)` 用 `/(^|\s)@(\S+)/g` 提取路径 → `{prompt, files}`；命令面板弹窗与侧栏输入框都实时渲染 `.attach-item` chips（`.quick-files`），oneshot payload 携带 `files`。
  - 后端 `resolveAtFiles(files, cwd)`：路径存在性 + **白名单校验**（默认 cwd / ALLOWED_CWDS / sessionDir 内，绝对路径同样校验），通过后以 `@绝对路径` 追加到 `pi --mode json` 的 argv（pi 原生处理文本/图片附件）；越权路径返回 `{ok:false, error}`。mock-pi json 模式跳过 `@` 开头的 argv token。
- 验证：disk sessions 4/4 带预览；合法 `@README.md` 通过、越权 `/etc/passwd` 被拒；语法 + smoke 6/6 通过。

## 第七轮：topbar 图标自定义 tooltip

- topbar 10 个图标按钮的原生 `title` 改为 `data-tooltip`（保留 `aria-label`），新增全局自定义 tooltip 组件：
  - CSS：`--tt-bg/--tt-text/--tt-border` 主题变量（light: 深蓝灰 #22263a；dark: #262b37 + 边框）；`.tt` fixed 定位卡片 + `::before` 箭头（`--tt-ax` 控制箭头水平偏移，`data-pos` 切换上下，默认 bottom）+ fade/scale 渐入动画。
  - JS：`initTooltips()` 用 mouseover/focusin 事件委托（兼容动态元素），120ms 延迟显示防划过闪烁，`scroll`/`resize` 时隐藏，防溢出视口自动校正；`setThemeUi(dark)` 统一维护主题按钮的 icon/aria-label/data-tooltip（主题按钮 tooltip 随主题显示"切换到浅/深色模式"）。
  - 验证：`node --check` 通过；`PORT=8765 npm run demo` 起 mock 服务已预览。

## 第八轮：历史对话增加删除功能

- **后端**：`SessionManager.deleteOnDisk(sessionFile)`——三重保护：路径必须 resolve 后位于 `sessionDir` 内（防路径穿越）、后缀必须 `.jsonl`、**删除前先 `refreshSessionFiles()`**（向所有活跃会话发 `get_state` 刷新 `meta.sessionFile`，因为 `switch_session` 后 meta 不会自动更新）再比对占用，占用中的会话拒绝删除；`fs.unlink` 删除。`index.js` 新增 WS 消息 `session.deleteDisk` → 成功后 `broadcast({type:'session.list', ...})` 让所有客户端刷新 + 回 `session.diskDeleted {sessionFile, removed}`。
- **前端**：`renderDiskSessions` 每项加 🗑 按钮（复用 `.session-item .close` 样式 + `.close.del`，hover 显示），`confirmDeleteDiskSession(s)` 用 openModal 二次确认（`.btn-danger`），确认后发 `session.deleteDisk`；`onServerMessage` 处理 `session.diskDeleted` 本地过滤刷新 + toast（新增 `.toast.ok` 样式）；新增 `pathEqual()` 前端路径比较。
- **新增测试** `test/delete-disk.mjs`（mock-pi 端到端）：正常删除 / 列表广播刷新 / 文件不存在 removed=false / 路径穿越拒绝 / 非 jsonl 拒绝且不误删 / 活跃占用拒绝 / 销毁后删除成功——13/13 通过；`npm run smoke` 6/6 无回归。
- **关键教训**：`switch_session` 是 RPC 透传，后端不解析 → 会话当前使用的磁盘文件必须主动 `get_state` 刷新，不能依赖创建时的快照。删除"正在使用"的文件会导致 pi 进程后续写入异常，占用保护是必要的。README WS 协议表已同步。

## 第四轮：输入 / 弹出 Pi CLI 原生斜杠菜单

用户要求"输入 / 时 出现 pi cli 内原本的那些交互"——原 Web 顶栏的命令面板是按钮触发模态框，但 composer 输入 / 直接当 prompt 发，体验断裂。本轮把 Pi CLI 自己的斜杠自动补全还原到 Web 输入框。

**实现**
- `public/index.html` composer 内插入 `#slash-menu`（listbox 语义）；输入框 placeholder 加 `输入 / 打开命令菜单`。
- `public/css/style.css` 加 `#slash-menu` + `.slash-group/.slash-item/.slash-cmd/.slash-desc/.slash-src/.slash-empty` 样式（用现有 token，`[hidden]` 覆盖 `display:none`，`@keyframes slash-in` 保留 `translateX(-50%)`），以及 `/hotkeys` 弹窗的 `.hotkey-row/.hotkey-key`。
- `public/js/app.js` 加 `BUILTIN_SLASH`（**与 pi v0.84.2 `dist/core/slash-commands.js` 的 `BUILTIN_SLASH_COMMANDS` 一一对应**，22 条）+ `slashLoadCmds()`（按 runtimeId 缓存 `get_commands`，合并内置 + 扩展/技能/模板）+ `slashRender/slashHighlight/slashMove/slashComplete/slashRun/slashClose` + `runSlashBuiltin` 动作映射。
- 键位：↑/↓ 选择 · Enter 运行 · Tab 补全（保留菜单）· Esc 关闭。输入 `#input` 的 `input` 事件触发 `slashSync()`，整行以 `/` 开头且无空格才弹出；含空格（参数模式）自动关闭。
- 动作映射：有 web 能力 → 调用现有函数（`/compact→compactContext`、`/export→exportHtml`、`/fork→showFork`、`/clone→cloneSession`、`/tree→showTree`、`/name→renameSession`、`/session→showStats`、`/new→createSession`、`/settings|model→打开侧栏并 focus/滚动 select`、`/import|resume→打开侧栏并 focus 会话搜索`）；无对应能力 → toast "Web 版暂不支持"（`/share/login/logout/trust/scoped-models/quit/changelog`）；`/reload→location.reload`；`/copy→剪贴板最后一条助手回复`；`/hotkeys→快捷键一览弹窗`。
- 扩展/技能/模板沿用 `dispatchPrompt('/' + name)`，与顶栏命令面板行为一致。

**端到端验证**（同一 Bash 调用内：mock 服务器 + 无头 Chrome + CDP 脚本）
- `PORT=4121 PI_BIN=node test/mock-pi.js npm start` 起服 → `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --headless --remote-debugging-port=9333 …` → Node 22 CDP 脚本驱动。
- 实测：输入 `/` → 菜单 26 项（22 内置 + 4 mock 扩展），分组"内置命令 / 扩展·技能·模板"；`/co` → 6 匹配（scoped-models/copy/login/compact/reload/compact-ext）；Tab 补全 `/scoped-models`；Enter 运行 `/compact` → toast `已压缩上下文：mock 摘要`；`/zzz` → 空态"没有匹配的命令"；Esc 关闭；清空关闭。
- smoke 6/6 无回归。
- **踩坑**：Bash 沙箱下后台进程**跨 turn 即死**（curl 一开始 200 后变 000），CDP/无头 Chrome/服务器必须**同一 Bash 调用内**起、跑、清；Node `fetch` 在沙箱里对本地端口 ECONNREFUSED（curl 正常），故浏览器自动化只能走 `await fetch('http://127.0.0.1:9333/json/list')` 是父 shell 而非 Node——所有 CDP 必须用 Node，所以端到端全部包在 bash 脚本里用 Node 驱动。
- 截图：`/tmp/slash-full.png`（全菜单）、`/tmp/slash-menu.png`（/comp 过滤态）。

## 第五轮：补全 /settings 交互（之前的 toast 占位不合需求）

用户反馈 `/settings` 没有继续的交互界面——上一轮只 `openSidebar()` + toast 占位，不符合 Pi CLI 真实行为。CLI 的 `/settings` 弹出完整的设置选择器（含模型、思考等级、自动压缩、steering/follow-up 模式等）。

**实现**
- `public/js/app.js` 新增 `showSettings(focusModel)`：先 `get_state` 拉取当前 session state 填入表单，再根据 RPC 类型补齐缺失项；表单项：
  - 模型（`settings-select`，值 = `state.modelOptions`，change → `set_model` RPC + 同步侧栏下拉）
  - 思考等级（`set_thinking_level` + 同步侧栏）
  - 自动压缩（开关，`set_auto_compaction enabled`）
  - 转向模式（`all` / `one-at-a-time`，`set_steering_mode`）
  - 后续消息模式（`all` / `one-at-a-time`，`set_follow_up_mode`）
  - 会话信息只读区：名称、消息数、id、文件
- `runSlashBuiltin` 中 `case 'settings' → showSettings()`；`case 'model' → showSettings(true)`（自动聚焦模型下拉，与 CLI 一致）。
- `public/css/style.css` 新增 `.settings-row / .s-label / .s-hint / .settings-select / .settings-info` 与 `.switch`（复选框自定义开关：track + thumb，`:checked` 切色/位移）。
- 辅助函数 `settingsRow(label, hint)` 和 `settingsToggle(checked, onchange)`。

**端到端验证**（CDP）
- `/settings` 回车 → 弹窗 `会话设置`，含 5 行 + 会话信息；selects 值为 get_state 初始值；toggle=true（autoCompactionEnabled）。
- 模型下拉切换 → toast `已切换模型: GPT-5.6`（mock set_model 已发）。
- 开关切换 → toast `已关闭自动压缩`（mock set_auto_compaction 已发）。
- `/model` 回车 → 同一弹窗；模型 select `document.activeElement === sel` 验证为 true。
- Esc 关闭。
- `node test/smoke.mjs` 6/6 无回归。
- 截图：`/tmp/settings-modal.png`、`/tmp/settings-model.png`。

## 第六轮：补全所有"带交互"的斜杠命令

用户反馈 `/settings` 之外还有其他带交互的命令也不完整。逐一对齐 Pi CLI v0.84.2 各内置命令的交互语义（已确认 RPC 类型：rpc-types.d.ts 里只有 model/thinkingLevel/steeringMode/followUpMode/autoCompactionEnabled/autoRetry 暴露给 RPC；**trust 和 scoped-models 无 RPC**）。

**新增交互弹窗**
- `/model` 与 `/scoped-models` → `showModelPicker(filter)`：搜索框 + 按 provider 分组的列表（`fmtProvider`），当前模型高亮带 `✓ 当前`，点击 → `set_model` RPC 并同步侧栏。`/scoped-models` 在 Web 版复用同一选择器（CLI 的 Ctrl+P 循环是终端特性，弹窗副标题注明）。
- `/resume` 与 `/import` → `showResumePicker()`：搜索框 + `state.diskSessions` 列表，点击 → `switchToDiskSession`（已有 ensureSession 兜底）。
- `/changelog` → `showAbout()`：版本/二进制/Node/会话目录/默认 cwd 弹窗（`/api/health`）。
- `/trust` → `showTrust()`：项目信任与安全配置弹窗，显示服务端 PI_APPROVE/ALLOWED_CWDS/默认 cwd（需后端暴露）。

**CLI 参数路由**（`routeSlashArgs`，对齐 `interactive-mode.js` 各 handleXxxCommand 的解析）：
- `/name <名>` → 直接 `set_session_name` RPC（CLI 一致，不开弹窗）
- `/compact <指令>` → `compactContext(instr)`（更新 `compactContext` 接收 `customInstructions` 参数，调用 `compact` RPC 带参）
- `/model <词>` → `showModelPicker(arg)`（预填搜索框）
- `/export <路径>` → `exportHtml(outputPath)`（`export_html` RPC 带 `outputPath`）
- 拦截位置：`sendUserMessage`（Enter 与发送按钮都走），返回 true 表示已消费。
- 非上述命令（扩展命令、未知 `/xxx foo`）按原路径走 `dispatchPrompt`。

**后端**：`/api/health` 暴露 `approve`/`allowedCwds`（来自 `config.js`）。

**端到端验证**（CDP，9 项）
- `/model` → 选择模型弹窗，分组 Anthropic/OpenAI，当前 claude-opus-5 ✓；点击 gpt-5.6 → toast `已切换模型: GPT-5.6`。
- `/scoped-models` → 同一弹窗。
- `/resume` → 恢复会话选择器（搜索框可用；mock 模式下磁盘列表为空显示空态，真实环境有历史会话）。
- `/changelog` → 关于弹窗含版本/Node/会话目录/默认 cwd。
- `/trust` → 信任弹窗：`--approve ❌ 未启用`、允许工作目录不限制。
- `/name my-cool-session` → 直接命名，session 名更新 + toast。
- `/compact 按模块总结` → 带 customInstructions 压缩 + toast 含指令摘要。
- `/model gpt` → 弹窗预填搜索 "gpt"，列表仅剩 gpt-5.6。
- `/export /tmp/out.html` → 导出 + toast 含路径。
- smoke 6/6 无回归。
- 截图：`/tmp/picker-model.png`。
