masknull/dsh-composer-history ↗★ 0
dsh-composer-history
为Web输入框提供Shell风格的输入历史回溯 适合习惯使用方向键上下翻看、修改和恢复历史发送消息的Web端用户。
同名套件的其他儲存庫
安裝
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:masknull/dsh-composer-history說明文件
閱讀完整 README ↗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 源码确认过的,没有靠猜。
-
拿得到官方的写入接口。
conversation.input.overlay(list 槽、session 作用域)由@deepseek-ai/dsh-client-ui-conversation渲染在常驻输入卡片内部,槽位目录里明确声明每个占用者都能拿到标准 propsuseInput(InputState快照)和inputActions(SessionInputShell.actions)。其中inputActions.setDraft(text)就是「整段草稿的程序化写入」:做占位符清理、按\n拆段落、光标落到末尾、并与撤销历史合并。 -
找得到稳定 DOM。 输入卡片是
[data-composer-card],里面的 Lexical contenteditable 是[data-composer-input]—— 都是ComposerContentEditable挂的稳定属性,不是带构建 hash 的 class。 -
抢得到按键、又抢得准。 官方 composer 的 keymap 只仲裁触发器菜单的按键,其余按键交给编辑器 —— 所以
↑/↓本来没有归属。监听器必须挂在卡片的捕获阶段:- 挂在编辑器上或冒泡阶段,Lexical 可能已经先动了光标,再读光标位置就会误判边界;
- 挂在
document上,一个页面上两个 composer 实例(主区 + 内嵌)会同时响应一次按键。 捕获挂在各自的卡片上,一次按键只会命中一张卡片;每个占用者只渲染一个hidden的 ``,它唯一的用途就是把监听器锚定到自己这张卡片。
-
不抢菜单的键。 触发器菜单的 viewport 带
role="listbox",它可见时插件直接放行。 -
对启动顺序免疫。 客户端插件是并发启动的,
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。