masknull/dsh-composer-history ↗★ 0

dsh-composer-history

为Web输入框提供Shell风格的输入历史回溯 适合习惯使用方向键上下翻看、修改和恢复历史发送消息的Web端用户。

包名
dsh-composer-history
兼容性
待验证
版本
0.1.0
许可证
MIT
最近更新
2026年10月14日

同名包的其他仓库

安装

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:masknull/dsh-composer-history

dsh-composer-history

给 DeepSeek Harness Web 输入框加上 shell 风格的输入历史:按 ↑ 翻出本会话里已经发出去的文字,按 ↓ 往回翻,翻过最新一条就把你原本在打的草稿还回来(原本是空的就清空)。

装上即用。没有设置页,不替换任何官方 UI。

English | 中文


它做什么

操作结果
输入框为空时按 ↑显示本会话发出的最后一条消息
光标在第一行时按 ↑同上(多行草稿里光标上下移动照常,不会被抢)
继续按 ↑逐条往回翻,翻到最老一条后停住
按 ↓逐条往回翻
翻过最新一条再按 ↓恢复到「开始翻历史之前」的草稿;当时为空则清空
翻历史过程中打字立即退出历史浏览,保留你打的字

几个值得知道的行为

  • 进入浏览态后,两个方向键都归历史。 多行消息翻出来后光标会落在末尾,如果还要求「在末行」才能按 ↓,就再也翻不回去了。所以一旦进入浏览态,↑/↓ 会持续翻历史;你一旦改动草稿、或把光标点到文字中间,浏览态立刻结束,方向键恢复成普通光标移动。
  • 发送会把指针重置回最新一条。 下一次 ↑ 从最新消息开始,而不是接着上次翻到的地方。
  • 连续重复的文字只记一条;下面两个发送信号也会去重,一次发送只入栈一次。
  • 下列情况完全不接管,按键还给官方行为:
    • Shift / Ctrl / Cmd / Alt + 方向键(选择文本、应用快捷键)
    • 斜杠命令菜单(/)打开时(那是菜单的导航键)
    • 提交正在 adjudicating / submitting 阶段(不许改写输入框)
    • 输入法组合中(isComposing / keyCode 229)
  • 上限 50 条,只存当前会话,只在内存里。不落盘、不跨会话、不发给模型。

安装

# 从 GitHub 仓库安装(pnpm 会把仓库拉进 profile)
dsh plugin --profile web add github:masknull/dsh-composer-history

安装后 profile 的 dsh.profile.bundles 会多出 dsh-composer-history,Web 端重新加载页面即生效。目前尚未发布到 npm;dsh plugin --profile web add dsh-composer-history 这种写法要等发布之后才可用。

卸载:

dsh plugin --profile web remove dsh-composer-history

环境要求

  • DeepSeek Harness >= 0.2.0-rc.1(在 0.2.0-rc.2 上实测)
  • Web profile。浏览器半边声明 dsh.client.platform: "web",在终端或 Desktop 原生键盘通路上不做事。

它怎么接进去的

下面每一处缝合点都是读官方 0.2.0-rc.2 源码确认过的,没有靠猜。

  1. 拿得到官方的写入接口。 conversation.input.overlay(list 槽、session 作用域)由 @deepseek-ai/dsh-client-ui-conversation 渲染在常驻输入卡片内部,槽位目录里明确声明每个占用者都能拿到标准 props useInput(InputState 快照)和 inputActions(SessionInputShell.actions)。其中 inputActions.setDraft(text) 就是「整段草稿的程序化写入」:做占位符清理、按 \n 拆段落、光标落到末尾、并与撤销历史合并。

  2. 找得到稳定 DOM。 输入卡片是 [data-composer-card],里面的 Lexical contenteditable 是 [data-composer-input] —— 都是 ComposerContentEditable 挂的稳定属性,不是带构建 hash 的 class。

  3. 抢得到按键、又抢得准。 官方 composer 的 keymap 只仲裁触发器菜单的按键,其余按键交给编辑器 —— 所以 ↑/↓ 本来没有归属。监听器必须挂在卡片的捕获阶段:

    • 挂在编辑器上或冒泡阶段,Lexical 可能已经先动了光标,再读光标位置就会误判边界;
    • 挂在 document 上,一个页面上两个 composer 实例(主区 + 内嵌)会同时响应一次按键。 捕获挂在各自的卡片上,一次按键只会命中一张卡片;每个占用者只渲染一个 hidden 的 ``,它唯一的用途就是把监听器锚定到自己这张卡片。
  4. 不抢菜单的键。 触发器菜单的 viewport 带 role="listbox",它可见时插件直接放行。

  5. 对启动顺序免疫。 客户端插件是并发启动的,apply 执行时 slots 服务可能还不存在,而且只监听 internal/service 也不一定兜得住(开发时真踩到了:占用者迟迟不出现)。因此插件用双通道取服务(ctx.slots + ctx.get('slots'))+ 有界重试(每 400 ms 一次、最多 25 次),服务一出现立刻注册。

「已发送文字」是怎么判定的

官方发送一条普通消息时,状态机的 phase 保持 plain(SubmitMachine.beginDetached),而 Session 的提交回声要等 sink 里附件编码的 await 之后才登记 —— 也就是说「草稿被清空」那一刻,唯一可观测的事实就是清空 + 一个发送手势。于是插件记录两个手势:

  • 编辑器上的 Enter(排除 Shift、Alt、Ctrl+Cmd 这些不发送的组合)
  • 输入卡片里最后一个 `` 的点击 —— 即主提交按钮:工具行里所有按钮(包括 conversation.input.right 的占用者)都在它前面,它的 class 带构建 hash,位置才是稳定属性

任一手势配上 700 ms 内的一次「非空 → 空」草稿变化,就把那份文字记进历史;同一个文本 1200 ms 内只记一次,用来吃掉两个信号的重叠。


已知限制

  • 用 Enter 发送和用主按钮发送都能记到,但手动清空输入框(全选删除)如果恰好发生在点击卡片按钮后约 700 ms 内,可能被误记一条。代价仅是多一条历史。
  • 历史存的是 InputState.draft(剪贴板投影),所以 @文件、技能引用这类 chip 翻回来后是纯文本,不再是引用芯片。
  • 不持久化:刷新页面历史就没了。这是刻意选择 —— 插件不往浏览器存储里写你输入的内容。
  • 不支持 Desktop 原生键盘桥:这是 Web 客户端插件。

开发

npm run check   # 对两个半边做 node --check
npm test        # 历史状态机的 Node 单元测试

lib/client.js 里的历史状态机(createHistory / recordSent / recallUp / recallDown)刻意做成不依赖 React 与 DOM 的纯函数,测试直接驱动它们;__internals 只为此暴露,Host、Loader 和浏览器内核都不读这个键。

浏览器 bundle 是自包含的单文件(window.__ModuleLoader__.load(...) + 工厂函数):客户端模块系统只解析平台种子表、已 materialize 的模块、boot graph 行和已注册工厂,跨文件的相对 require 解析不到。

License

MIT —— 见 LICENSE。