# Codinput 开发与维护须知

> 面向开发/贡献者的维护文档：接管机制、停靠几何的硬约束与验证清单。用户向文档见 [README](./README.md)（中文）/ [README.en](./README.en.md)（英文）。

Codinput 是**纯客户端插件**：宿主半边（`lib/index.js`）只作为 bundle 挂载载体存在，让 `dsh.client` 扫描发现浏览器半边。所有 UI 工作在 `lib/client.js`，经批量端点注入页面（0.1.1 为 `/plugins/codinput/client.js` 单文件路径；**0.1.2 起合并为 `/plugins/??<插件清单>` 批量请求，单文件路径不再单独提供**）。

## 本地开发（link 安装）

在 DSH 的 profile 中登记本包。Windows 下若检出路径含空格，pnpm 的 link 支持不佳——建议检出到无空格路径，或用目录联接：

```powershell
# 建立无空格路径的联接（PowerShell 管理员或开发者模式）
cmd /c mklink /J "C:\Users\<你>\.dsh\plugins\codinput" "C:\实际路径\Codinput"

# 在联接路径上安装（仓库根目录即插件包）
dsh plugin --profile web add link:C:/Users/<你>/.dsh/plugins/codinput
```

安装后重启 `dsh web`，刷新浏览器页面即可。

> 若安装后启动报 `duplicate prefix route`，说明 profile 的 bundles 里同时存在独立的 `dsh-better-sidebar` 和 `@linxin666/dsh-web-all`（后者会自行挂载一份）——移除 bundles 数组中独立的 `dsh-better-sidebar` 条目即可（依赖保留供解析）。

## 验证清单（开发自测）

```powershell
# 语法
node --check lib/client.js && node --check lib/index.js
# 安装后（重启 dsh web）
# 0.1.2+ 首页带 token 门禁：用启动日志打印的带 ?token= 地址建立会话，再查 boot 清单
curl "http://127.0.0.1:3080/?token=<启动日志中的token>" -c cookies.txt -L   # 建会话（200/303）
curl -b cookies.txt "http://127.0.0.1:3080/" | findstr codinput             # boot 清单含 codinput/client.js
```

浏览器侧：开关出现 → 接管渲染 → 多行输入行号增长 → 当前行色带横贯行号栏与编辑区、当前行号转正色（软换行模式下随换行/滚动正确跟随）→ `/` 或 `@` 候选**鼠标点击**补全后两条色带与 Ln/Col 同步落到插入行 → 分屏预览渲染 h1/strong/code/li → Ctrl+Enter 发送后聊天流出现普通文本消息、编辑器清空、行号重置 → 拖拽标题栏出现四区提示、落点判定底/左/右/浮动，布局两两互转走 FLIP 形变（无跳切）→ 新会话 hero 态停靠卡片不遮右上角展开按钮 → 刷新页面布局与草稿保持。**0.1.2 回归三件套**：空稿点击开关立即接管（setDraft 同值去重回归）→ 多行草稿下 hero 组（logo/工作区/模式行）不上推（占位钳高）→ 停用后原生输入框带原稿自然恢复（钳高放开、自动增高）。

## 发布

1. 更新 `package.json` 的 `version`；
2. commit + push；
3. `npm publish`（2FA 动态码）；
4. Release 由 CI 自动创建（`.github/workflows/release.yml`）：push 后立即对齐一次，另有每 30 分钟轮询兜底（npm publish 晚于 push 的场景）；也可在 Actions 页手动 Run workflow 立即触发。门控 = `package.json` 版本与 npm latest 一致才以当前 HEAD 打 tag 建 Release（保证 tag 与代码对应），已存在的版本跳过。

## 技术实现

- **composer 接管**：注册 `conversation.composer` 链式槽位条目（`priority: 10`）。链选举按 priority 升序尝试，第一个非空 selector 获胜——官方审批接管（`-10`）、提问接管（`0`）优先于 Codinput；它们出现时 Codinput 自动让位，默认输入框在无接管时原样恢复（隐藏而非卸载，textarea DOM 存活）。
- **开关桥接**：selector 只读模块级偏好（`prefs.enabled`）。切换开关后调用一次 `inputActions.setDraft(当前草稿)` 戳一下输入机——`draftRev` 单调递增使 ConversationRoot 重渲染，composer 链随之重新选举。这是「纯函数 selector」感知外部状态变化的唯一桥。
- **发送链路**：`inputActions.setDraft(编辑器全文)` → `inputActions.submit()`。提交进入官方裁决管线（斜杠命令裁决 / 引用序列化 / 默认消息汇）。发送**接受**后输入机清空草稿，编辑器监听到 `draft === '' && phase === 'plain'` 即清空并重置行号；`phase === 'claimed'`（命令接管）则取消清空。文本经 `MarkdownText` 仅用于本地预览，**上线内容永远是原始字符串**。
- **触发菜单**：`ctx.inject(['inputTriggers','sessions'])` 解析官方 `InputTriggerController`（`sessions.scope(sessionId)` → `inputTriggers.sessionOf(actx)`）。编辑器每次输入/移动光标调用 `controller.track(draft, caret, guard, draftRev)`（guard 由 `InputState.phase` 映射：plain→plain、claimed→claimed、其余→frozen），键盘 `↑/↓/Enter/Tab/Esc` 走 `controller.arbitrate(...)`，候选项与官方 MenuView 同款在 `mousedown` 拾取（`controller.pick(source, index)`）；候选菜单 UI 由本插件渲染（`controller.menu` store）。**注意**：`track` 的 `draftRev` 必须是机器的*当前*版本号——渲染快照滞后一拍，插件用 `revRef` 镜像（外部事务以快照校准、自己写入后 +1 预测），否则 pick 时 span CAS 失配、点击候选无法补全。**全局 dismiss 截停**：官方 MenuView（隐藏 composer 内仍挂载）在 document 捕获段监听 pointerdown，对其 composer 卡外的一切按下 dismiss 共享菜单 store——本插件菜单开着时用 window 捕获段 guard（先于 document 触发）对指向本插件菜单的 pointerdown `stopPropagation`，否则鼠标点击候选会先被 dismiss、pick 因 `state.open=false` 静默失效（键盘 Enter 走 arbitrate 不经指针事件，不受影响）。**0.1.2 适配三件**：① 官方输入元素定位走 `nativeEditableOf()`——≤0.1.1 是座位内 `<textarea>`，0.1.2 起换成 Lexical contenteditable DIV（稳定属性 `[data-composer-input]`），按 textarea 找的接管/隐藏/还原逻辑会全数落空（双输入框共存）；② 开关桥接的 `setDraft` 同值写入被 0.1.2 去重（空稿时 `''→''` 不增 draftRev、链不重选举、点开关无反应，真实敲字后又能接管即此症）——改为戳「前缀零宽字符、60ms 后还原」，值必变且不可见不增行；③ onChange 的镜像 setDraft 使 Lexical 占位卡随草稿行数自动增高，hero 组被逐行上推——隐藏时 `pinEditableHeight` 钳到实时计算的单行高（line-height+纵向 padding/border，不假设主题常量），停用/卸载 `unpinEditableHeight` 放开，原生框恢复自然增高（草稿原样）。
- **工具行开关**：注册 `conversation.input.right` 列表座位条目（`id: codinput-toggle`），渲染在模型选择左侧。
- **设置分区**：注册 `settings.section` 列表槽位条目（`id: codinput`），偏好仍存 `localStorage`（无宿主往返）。
- **停靠几何**：`contentFrame()` 以纯几何方式测量中央列边界（**首选锚点 = better-sidebar 写在 AppFrame 中心栅格项上的 `[data-dsh-center-col]`**：shell 自己测量定位的权威中心列，左/右/底界直接取其 rect。右面板挤压走 frame 的 `padding-right`、底部面板挤压走中心列的 `margin-bottom`，均带过渡、逐帧反映在此矩形上——停靠卡据此逐帧跟随，与面板动画天然同步。**教训：面板滑入是 transform 过渡、布局挤压在收尾才落地，任何测量「已落地布局」的方案都会让卡片等到动画结束才动（侧栏先到终点、卡片后行动，无相对静止）**——已废弃：seat 祖先链（左界=「左缘 >4px」最小左值/右界=「右缘 <视口宽-4」最大右值）、workbench rect（随类翻转整段跳变）、`--dsh-sidebar-width` 意图前置（变量常驻但挤压落地时机同样在收尾）；seat 祖先链降级为无 `[data-dsh-center-col]` 标签（未装 better-sidebar/未定位）时的回退。**面板本体跟随**：滑入/滑出是面板自身的 transform+width/height 过渡，rect 逐帧反映视觉位置——在 `[data-dsh-panel-host]`（stable 属性）下按形状取值：竖条（height≥width、贴右缘、宽 ≤70% 视宽）左缘为右界，横条（width>height、贴底缘、高 ≤70% 视高）上缘为底界；收起态 translate(102%) 矩形在视口外，min 自然无效化无需状态分支。顶界=可见 header 最宽者下缘、底部面板旧探测（`bottomPanel` 类名 + 可见高度 ≥24px）降级为宿主缺失时的回退；data 属性在部分构建里会被剥掉，不可依赖）。**顶界下限**：右上角「展开侧边栏/展开底部面板」按钮组下缘（`topControlsBottom()`，带 500ms TTL 缓存——该函数位于 contentFrame 内、contentFrame 被 rAF 逐帧采样调用，全页 button 扫描进热路径会挤占帧预算，Live2D 挂件等逐帧动画插件会掉帧音画失同步；扫描廉价优先：带 aria-label/title 的按钮只读属性，落空才 textContent 全扫兜底）。停靠面板、拖拽提示区、尺寸钳制都以此为界；**rAF 逐帧比对 frame 对所有非内嵌布局生效**（bottom 也要——底部面板开合/调高必须即时重排）。**rAF 跟随必须 flushSync 同帧提交 + 跟随期挂 `codinput-notrans`（同步两件套，缺一即复现「面板先到终点、卡片后行动」）**：rAF 回调里普通 setState 的 commit 排在其后的宏任务，样式落在本帧绘制之后，卡片起步恒比面板晚一拍；`.16s` 几何过渡遇上逐帧新目标退化为恒定滞后的追赶（低通滤波），面板已走完卡片还在缓动——几何过渡只服务无逐帧采样的离散跳变，边界逐帧变化期间必须全程禁用（跟随结束 = 边界连续 2 帧不动，摘 notrans）。注意 FLIP 量测后的无条件 `classList.remove('codinput-notrans')` 会把跟随期 notrans 从 DOM 摘掉，而 React 虚拟 className 无变化不会补写——必须按 `frameHot` 补回，否则底部面板抬升（flipKey 恰在动画起点翻转）的整个跟随期过渡复活。**底部面板展开时停靠卡片抬升**：面板是悬浮覆盖层，文档流内的底部停靠卡片下部会被盖住——rootStyle 的 bottom 分支探测面板高度，展开时改走 fixed（`bottom = 面板高度 + 8`），收起后落回文档流（与 heroLike 贴底浮层同一套机制）。**底部悬浮覆盖聊天（`dockOverlay`，默认开）**：layout=bottom 且非嵌入时卡片一律 fixed 悬浮（覆盖聊天记录，聊天内容不让位）；关闭则卡片嵌入输入区（文档流内，聊天让位）。heroLike/bpLift 本就悬浮、不受此开关影响；悬浮 ⇄ 嵌入是定位体系切换，已纳入 flipKey（'o'/'x'）。**左右停靠覆盖/避让（`sideOverlay`，默认关 = 避让）**：避让目标不是中央列整体，而是其下 `[data-conversation-scroll]` 聊天滚动区（margin 避让）——中央列的另一个子节点 `mL8Uca_view`（顶部「对话/轨迹」标签条）是滚动区的兄弟节点，保持全宽不被挤压；`sideOverlay=true` 时卡片悬浮覆盖、移除避让。**`--dsh-sidebar-width` 中和（不能删变量）**：better-sidebar 的底部面板调高提交会把右面板宽度无条件写进该变量（官方 `#root` 据此 margin-right 预留右列），右面板收着也写——主体列被挤成「右侧栏展开」的样子且**持续存在**（Codinput 关闭时也在）。**删变量会和它的写入器打架（写→删→写→反复闪烁）**；正确做法是**模块级常驻门控** `installRightPanelGate()`（模块加载即安装，与 Codinput 启停完全解耦）：MutationObserver 同时监听 documentElement 的 style 变更与 **body 的 `data-dsh-sidebar-collapsed` 属性**（better-sidebar 的 Sidebar shell 按 panelOpen 取反维护它——面板开合的权威即时信号），右面板收起时给 body 挂 `codinput-rp-closed`，CSS 用 `body.codinput-rp-closed #root{margin-right:0 !important;width:100% !important}` 中和——变量随便它写，渲染不吃它。**不要用几何判定（workbench rect）**：面板展开走 transform 滑入，mutation 触发的瞬间 rect 仍在屏外，会误判为收起且让内容列的让位延迟半拍（比面板滑动慢约 400ms）；属性信号随状态即时翻转，内容列与面板滑动同步过渡。组件巡检不做此事（与启停解耦），卸载 cleanup 也不摘标记类。**层级**：`.codinput-fixed` 带 `z-index:40`——空白会话恢复的官方 hero 容器（composerStack）是 `position:relative;z-index:1`，卡片若为 `z:auto` 会被其盖住（停靠卡片拉高后 hero 文字从卡片里"透出"、顶边中段拉伸热区被挡）；40 压过内容层、低于拖拽提示层（2147482000）；浮动卡内联 `z-index:900`——压过官方内容层与 composer 浮层（≤100），让位图片/附件预览（1000）与消息反馈弹层（1100），预览时被盖住是预期行为。**贴底浮层态（heroLike bottom）与面板抬升态走的是内联 `position:fixed`，不带 `.codinput-fixed` 类——必须在 rootStyle 内联 `zIndex:40`**，否则透底与热区失效会复发。**布局族切换走 FLIP 形变**（`flipKey` = layout/embedded/hero/bpLift/portal 就绪的组合）：跨挂载点（座位 ↔ 传送门）重挂或改定位体系时 CSS 过渡无从生效，提交后测新旧矩形、从旧矩形反向 translate+scale 起步动画回新矩形；起点矩形由 rAF 采样维护、形变期间暂停采样，连续快速切换以上一跳目标接续，transitionend 只认卡片自身 transform 并有 340ms 兜底，标签页模式清起点矩形。**几何 CSS 过渡必须覆盖全部六个定位属性**（left/top/right/bottom/width/height）——漏 `right` 曾导致右侧停靠跟随右栏开合时瞬移（右停靠定位走 `right`，不在过渡列表里就没有动画）。**当前行色带必须与行号栏同源渲染**：编辑区色带的初始 `top` 由 React 从 `caretLine` 渲染（与行号栏色带同源），`updateCurBands()` 只补滚动偏移与软换行视觉行实测——纯命令式补写一旦错过首帧，初始就是 CSS 的 `top:0`，两条色带错位 10px。镜像 effect 的 rAF 里 `setCaret(at)` 必须与真实选区同步，否则行号栏色带/`.cur` 行号/Ln-Col 统计与编辑区色带各停两行。**行号模式必须禁用软换行**（`.codinput-root:not(.codinput-wrap) .codinput-ta{white-space:pre}`）：textarea 的 UA 默认 white-space 是 pre-wrap，长行软换行会让行号与文本错位。品牌图标为 lucide file-code-corner（`IconFileCodeCorner`，用户选定），用于卡片标题、better-sidebar 标签与悬浮球。**applyDrop 必须用 `prefsState.layout` 判定 wasTab**——内嵌实例的 `layout` 变量被硬编码为 'bottom'（嵌入渲染几何），用它判定永远 false，拖出面板时 closeTab 不执行 → 幽灵标签残留。
- **侧栏标签页**：`ctx.inject(['betterSidebar'])` 注册 `TabDescriptor`（`id: 'codinput'`，`single: true`）。**认领规则 = 仅当 Codinput 停用时**：better-sidebar 会迟到地重复触发 `onOpen`/`onActivate`（其自身的异步恢复），1.5s 启动守卫挡不住——若运行中仍认领，会把正在运行的编辑器偷进标签页；运行中一律 `return`，移动到标签页用「移到这里」按钮或拖放。**`openTab` 落在 `activePane`**（better-sidebar 既定语义，对已存在的 single 标签只激活不搬移）——`openSidebarTab(which)` 必须先关掉开在另一面板的 codinput 标签、再激活目标面板的现有标签把 `activePane` 搬过去、最后 `openTab`，落点才是用户拖放的面板（否则拖底面板标签行会落到右侧栏）。标签页组件爬祖先链找 `bottomPanel` 容器自纠所在面板（pane 树里也有 workbench 容器，不能用它早退判定）；`onClose` 回退 `prevLayout`。**空目标面板树**（面板里一个标签都没有，activePane 无从搬移，openTab 会落进另一侧面板）：先点官方空面板「新建标签卡片」（`clickPaneEmptyCard`，合成 click 即可）实体化一个原生标签让 activePane 随焦点移动，codinput 落地后关闭该种子标签只留 codinput；卡片点击失败则放弃标签页，由 applyDrop 回退为该侧固定停靠。**标签 × = 完全退出 Codinput**（`enabled:false` + 布局回退 prevLayout）——closeTab 的 onClose 不触发 composer 链重选举，座位上的旧卡片会残留，必须补一次重选举戳（`composerKitRef` 在 CodinputToggle 渲染时 stash 链套件，onClose 经 `forceComposerReelect` 补戳，零宽字符保草稿）；相应地**拖出面板**也走 closeTab → onClose，那是布局变更不是退出——拖出后的 setPrefs 必须重新断言 `enabled: true`。**死角自愈**：layout 指向标签页但编辑器 DOM 不在场有两种可能——面板收起时 better-sidebar 会卸载隐藏面板的标签内容（0.1.2 实测），这是「被收缩隐藏」的正常形态（悬浮球接管输入入口），**不是死角**；只有 better-sidebar 状态树里 codinput 标签真被删了（`codinputTabInTree()`，不能以 DOM 在场性判定）才是死角，连续 3 拍回退 `prevLayout` 并 closeTab 摘掉幽灵标签。组件里不能经 `tabProps.ctx` 访问未声明服务（运行时守卫拒绝），输入套件经自己声明的 `inject` 构建。**0.1.2 投影迁移**：`sessions.currentProvideInfo` 已被移除，`provideInfoSource` 改为重建投影——当前会话 id 取 `sessions.list.getSnapshot().current`，输入壳 = `conversation.input.shell(id)`（SessionInputShell：`state`=InputState 观察、`actions`=InputActions），会话观察 = `sessions.binding(id).session`（缺失则 null 降级，仅 running 指示灯退化）；组合成 HostObservable，sessionId 变化才重建快照身份（useSyncExternalStore 依赖恒定身份）。
- **状态栏弹层互斥**：权限/模型/上下文三个弹层的 `open` 提升到 `CodinputEditor`（`popOpen: 'perm'|'model'|'ctx'|null`），组件签名收 `open` + `onOpenChange: setOpen` 别名保持内部调用不变；父层回调经 `useCallback([])` 稳定（`useDismissOnOutside` 的空依赖闭包捕获的是首帧回调，必须稳定才不丢引用）。
- **附件栏**：草稿图片经官方 `conversation` 服务（root 单例）的 `draftImages(ids)` 解析 `{file, previewUrl}`；`inputActions.removeImage(id)` 移除。调试口：`localStorage.codinput.debug='1'` 时暴露 `window.__codinputIA/__codinputConv`。
- **信息行交互**：权限提交 = `sessions.binding(sessionId).session.command('/permission <id>')`（与会话命令 RPC 同通道），图标为官方 `permissionGlyphs` 的盾形 SVG（PermissionSelect 原文摘录）；模型菜单为官方两行结构（「模型」「推理等级」），选择 = `modelDirectories.directoryFor(sessionId)` 的 `store`（读）+ `load()`（刷新目录）+ `select({provider, model, reasoningEffort?})`（提交）；投影经 composer 链标准套件的 `useProjection` 直接读取，模型 displayName 从目录分组的 `models[].name` 解析。权限文案与官方 `displayPermissionPreset` 一致（`danger-full-access` → Full access）。**状态栏与卡片根节点都不能 `overflow:hidden`**——权限/模型/上下文弹层锚定在状态栏内（`popwrap` position:absolute），状态栏裁剪会让弹层不可见（「点击不展开」），根节点裁剪会让短卡片时弹层上部被切；窄卡片防溢出改由可收缩项自带 ellipsis（统计文本、模型名）+ 圆角改由首尾子元素（toolbar/status）自带 radius 承担。
- **贴底浮层判定**：检测周期 300ms `setInterval`（**后台标签页 rAF 被冻结，不能用 rAF**——会话切换恰好发生在后台会永久漏判）；信号 = 消息视图容器高度（<40px 视为空白会话）。注意消息槽位容器是 `display: contents`——不生成盒子，`rect/scrollHeight` 恒 0，必须穿越到真实后代量高度（`maxVisibleHeightOf`）。**座位查找必须带回退**：卡片在浮动/标签页布局下挂在 body 传送门里，`el.closest('[data-composer-seat]')` 会落空导致检测早退、`heroLike` 永远停在 `false`（表现：浮动布局下新会话 hero 元素消失，且从正式会话切回新会话也不恢复）——先 `closest` 再回退 `document.querySelector`，检测的是会话流本身，与卡片挂载点无关。**退出必须还原因巡检改动的官方 DOM**（hero class、输入卡包装 `display:none`、文本域 `visibility:hidden`）：组件因 `enabled=false` 卸载时定时器停摆，残留会让默认输入框不回来（「叉掉后输入框消失」）；用 mount-only effect 先声明的 `unmountedRef` 区分真卸载与 deps 变化——deps 变化（布局/hero 翻转）时不能还原，否则官方输入卡会闪现共存。

## 已知边界

- 接管期间默认输入框的工具行（图片上传、访问模式、模型选择、dock 统计行）随官方接管语义一并隐藏——这与审批/提问接管的行为一致；需要时一键退出即恢复。
- `@` 文件候选依赖 DSH 工作区文件索引，冷启动（刚开页面/大工作区）首次查询可能等待数秒或短暂空结果，属官方源行为；过滤时每敲一键都会以新 query 重发 `fileReferences.list` / `sessionReferenceResolver` RPC 并把菜单重置回「加载中」（官方 menu reducer 语义，官方输入框同速）——长等待来自宿主索引，非本插件引入。
- 选中无参数命令（如 `/compact`、`/export`）立即执行：官方命令决策表（菜单列 host bare → detached execute），非本插件行为。
