dsh-focus-overlay 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +120 -0
- package/README.md +68 -48
- package/lib/client.js +281 -53
- package/package.json +5 -3
package/README.en.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<h1 align="center">dsh-focus-overlay</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="README.md">中文</a> | English
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
A <b>focus mode</b> for the DeepSeek Harness (DSH) web GUI: one click to a full-screen reading view that hides the header and composer and folds the AI tool-call flow into a one-line summary — leaving only the conversation between you and the AI.<br>
|
|
9
|
+
Text and image rendering reuse the official primitives, matching the chat view 1:1.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<img src="https://badgen.net/npm/v/dsh-focus-overlay" alt="npm version">
|
|
14
|
+
<img src="https://badgen.net/badge/license/MIT/green" alt="license">
|
|
15
|
+
<img src="https://badgen.net/badge/dsh/%3E%3D0.1.0-rc.5/blue" alt="dsh version">
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- **Full-screen display** — covers the whole UI, giving all vertical space to the conversation
|
|
21
|
+
- **Hide the header and composer** — a thin top bar remains, the input area is tucked away
|
|
22
|
+
- **Fold tool calls** — an assistant turn collapses into a one-line summary
|
|
23
|
+
- **Quick navigation** — a right-side node navbar with hover preview and click-to-jump
|
|
24
|
+
- **Auto-focus & reminders** — after a reply completes, optionally auto-enter focus at your question; while in focus, get a toast for a new reply or when the AI is waiting on you
|
|
25
|
+
|
|
26
|
+
These features are designed to give **small screens** more content space: tucking away the persistent header/composer and tool steps lets the conversation use as much of the screen as possible.
|
|
27
|
+
|
|
28
|
+
## Screenshots
|
|
29
|
+
|
|
30
|
+
**Off — the normal chat view**
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
**On — focus mode**
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
<!-- Drop your screenshots into screenshots/:
|
|
39
|
+
- before.png — the normal chat view (header / composer / tool cards)
|
|
40
|
+
- after.png — focus mode (full-screen overlay + summary line + right-side navbar)
|
|
41
|
+
Optionally add navbar.png as a navbar close-up. -->
|
|
42
|
+
|
|
43
|
+
Once in focus mode, an assistant turn no longer shows every step — it folds into a single summary line:
|
|
44
|
+
|
|
45
|
+
> ran 2 commands, edited 3 files, read 5 files, ran 1 search
|
|
46
|
+
|
|
47
|
+
## Capabilities
|
|
48
|
+
|
|
49
|
+
| Feature | Description |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| Full-screen overlay | Registered into `shell.overlay` (additive, `replaceRisk: none`) — covers header / composer / sidebar, giving all vertical space to the conversation |
|
|
52
|
+
| Official rendering | Assistant text through official `MarkdownText` (GFM + code highlighting + TeX); user messages through `MessageText`; user bubble uses the official blue, borderless style |
|
|
53
|
+
| Image resolution | Assistant `image` blocks resolve through `conversation.resolveImage`, rendering session-authorized images 1:1 |
|
|
54
|
+
| Tool-call folding | Tool calls / commands / context injections fold into a categorized summary line (commands / edits / searches / reads / directory listings / subagents / todos / goals / workflows / skills / questions / plans / background jobs) |
|
|
55
|
+
| Precise scroll preservation | Opens at the message you're reading in the chat (aligned by `seq`), not from the top |
|
|
56
|
+
| Right-side node navbar | One dot per user message; active pill follows scroll, hover preview, click to jump, auto-hidden under 2 messages |
|
|
57
|
+
| Back to latest | A centered "↓ back to latest" floating button appears when you scroll away from the bottom |
|
|
58
|
+
| File mentions | Wires `chatFileMentions`, turning inline-code tokens that name real files into clickable links |
|
|
59
|
+
| i18n | Chinese / English copy, follows the UI language |
|
|
60
|
+
| Settings page | Navbar toggle, open-position strategy, text-area width — persisted to `localStorage` |
|
|
61
|
+
| Auto-enter focus | After a reply **completes normally**, auto-open focus at your question; abnormal endings (stop / error / max-tokens / interrupt) never fire |
|
|
62
|
+
| Reply / waiting reminders | In focus, a "New reply ready + View" toast on completion; an "AI is waiting for your reply + Reply" toast on a question/approval, auto-cleared once answered |
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
Requires the `dsh` CLI (`>= 0.1.0-rc.5`).
|
|
67
|
+
|
|
68
|
+
**From npm (recommended)**
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
dsh plugin --profile web add dsh-focus-overlay
|
|
72
|
+
dsh web
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**From GitHub**
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
dsh plugin --profile web add github:boogoo619/dsh-focus-overlay
|
|
79
|
+
dsh web
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
> A git install pulls the **source** and builds it in place via the `prepare` script. pnpm ≥10 refuses to run `prepare` until you allow it; the first `add` fails, then follow the `dsh` hint and add the exact package key to that profile's `pnpm-workspace.yaml`:
|
|
83
|
+
>
|
|
84
|
+
> ```yaml
|
|
85
|
+
> allowBuilds:
|
|
86
|
+
> dsh-focus-overlay: true
|
|
87
|
+
> ```
|
|
88
|
+
>
|
|
89
|
+
> Then re-run `add`. **This authorization lets the package's code run on your machine at install time** — only grant it to sources you trust, and pin the commit (`github:boogoo619/dsh-focus-overlay#<sha>`).
|
|
90
|
+
|
|
91
|
+
**Local directory / tarball**
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
dsh plugin --profile web add ./dsh-focus-overlay # local directory
|
|
95
|
+
dsh plugin --profile web add ./dsh-focus-overlay-0.1.0.tgz # tarball
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Restart `dsh web` after install.
|
|
99
|
+
|
|
100
|
+
## Usage
|
|
101
|
+
|
|
102
|
+
1. Open a session and click the **"Focus"** button in the session header action row.
|
|
103
|
+
2. You enter the full-screen focus view: a thin top bar (session title + exit) and the conversation only, with tool steps folded into summary lines.
|
|
104
|
+
3. Hover the right-side dots to preview, click to jump; a centered "↓ back to latest" button appears when you're not at the bottom.
|
|
105
|
+
4. Press `Esc` or click "Exit focus" to return — the original UI is untouched.
|
|
106
|
+
|
|
107
|
+
## Settings
|
|
108
|
+
|
|
109
|
+
Sidebar "Settings → Focus Mode":
|
|
110
|
+
|
|
111
|
+
| Option | Description |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| Show right-side navbar | Toggle the nav dots (default on) |
|
|
114
|
+
| Preserve chat position on open | Open at your current reading position; off opens at the latest (default on) |
|
|
115
|
+
| Text width | 480–1200px slider for the reading column (default 760px) |
|
|
116
|
+
| Auto-enter focus mode when a reply completes | On a normal completion: auto-open at your question when closed, or a "New reply ready" toast when open; an "AI is waiting" toast on a question/approval (default off) |
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
[MIT](./LICENSE)
|
package/README.md
CHANGED
|
@@ -1,55 +1,96 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">dsh-focus-overlay</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">中文 | <a href="README.en.md">English</a></p>
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
<p align="center">
|
|
6
|
+
为 DeepSeek Harness(DSH)Web GUI 提供的<b>专注模式</b>:一键全屏阅读,隐藏标题区与输入区,把 AI 的工具调用流程折叠成一句话摘要,只保留「你与 AI 的对话」本身。<br>
|
|
7
|
+
文本与图片渲染复用官方原语,与聊天视图 1:1 一致。
|
|
8
|
+
</p>
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
<p align="center">
|
|
11
|
+
<img src="https://badgen.net/npm/v/dsh-focus-overlay" alt="npm version">
|
|
12
|
+
<img src="https://badgen.net/badge/license/MIT/green" alt="license">
|
|
13
|
+
<img src="https://badgen.net/badge/dsh/%3E%3D0.1.0-rc.5/blue" alt="dsh version">
|
|
14
|
+
</p>
|
|
8
15
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
|
|
16
|
+
## 功能
|
|
17
|
+
|
|
18
|
+
- **全屏显示** —— 覆盖整个界面,把纵向空间全部留给对话
|
|
19
|
+
- **隐藏标题栏和输入栏** —— 顶部只留一条极窄栏,底部输入区收起
|
|
20
|
+
- **折叠工具调用** —— 一轮 AI 回复折叠成一句话摘要
|
|
21
|
+
- **快速导航** —— 右侧节点导航条,悬停预览、点击跳转
|
|
22
|
+
- **自动专注与提醒** —— 回复完成后可自动进入专注并定位到你的提问;专注中收到新回复、或被 AI 提问等待时弹出提醒
|
|
23
|
+
|
|
24
|
+
这些功能专为**小屏幕**提供更多内容呈现空间:收起常驻的标题/输入区与工具步骤,让有限的屏幕尽可能多地展示对话。
|
|
25
|
+
|
|
26
|
+
## 效果
|
|
27
|
+
|
|
28
|
+
**关闭 —— 普通聊天视图**
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
**开启 —— 专注模式**
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+
<!-- 截图请放到 screenshots/ 目录:
|
|
37
|
+
- before.png —— 普通聊天视图(含标题区/输入区/工具卡)
|
|
38
|
+
- after.png —— 专注模式(全屏遮罩 + 摘要行 + 右侧导航条)
|
|
39
|
+
可再补一张 navbar.png 作为导航条特写。 -->
|
|
40
|
+
|
|
41
|
+
进入专注后,一轮 AI 回复不再逐个展示步骤,而是折叠成一行摘要:
|
|
42
|
+
|
|
43
|
+
> 运行了 2 个命令,编辑了 3 个文件,读取了 5 个文件,搜索了 1 个正则
|
|
44
|
+
|
|
45
|
+
## 能力
|
|
46
|
+
|
|
47
|
+
| 功能 | 说明 |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| 全屏专注遮罩 | 经 `shell.overlay` 注册(纯增量、`replaceRisk: none`),覆盖标题区/输入区/侧栏,纵向空间尽给对话 |
|
|
50
|
+
| 官方渲染 | AI 文本走官方 `MarkdownText`(GFM + 代码高亮 + TeX),用户消息走 `MessageText`,用户气泡用官方蓝底无描边样式 |
|
|
51
|
+
| 图片解析 | assistant 的 `image` 块经 `conversation.resolveImage` 解析,会话授权图片 1:1 显示 |
|
|
52
|
+
| 工具调用折叠 | 工具调用 / 命令 / 上下文注入按类型计数折叠成摘要行(命令/编辑/搜索/读取/列目录/子代理/待办/目标/工作流/技能/提问/计划/后台任务) |
|
|
53
|
+
| 精确保留位置 | 进入专注时定位到聊天视图中正在阅读的消息(按 `seq` 对齐),而非从头开始 |
|
|
54
|
+
| 右侧节点导航条 | 每个 user 消息一个圆点,激活药丸跟随滚动、悬停预览、点击平滑跳转,少于 2 条自动隐藏 |
|
|
55
|
+
| 回到最新 | 离开底部时显示居中的「↓ 回到最新」悬浮按钮 |
|
|
56
|
+
| 文件提及 | 接 `chatFileMentions`,内联代码命中真实文件时变成可点链接 |
|
|
57
|
+
| i18n | 中 / 英文案,跟随界面语言 |
|
|
58
|
+
| 设置页 | 导航条开关、打开定位策略、文字区宽度,持久化到 `localStorage` |
|
|
59
|
+
| 自动进入专注 | 回复**正常完成**后自动打开专注并定位到本轮你的提问;异常结束(停止 / 报错 / 超 token / 打断)不触发 |
|
|
60
|
+
| 回复 / 等待提醒 | 专注中回复完成弹「新回复已生成 + 查看」;AI 提问 / 审批等待时弹「AI 正在等待你的回复 + 去回复」,回答完自动消失 |
|
|
17
61
|
|
|
18
62
|
## 安装
|
|
19
63
|
|
|
20
64
|
需要 `dsh` CLI(`>= 0.1.0-rc.5`)。
|
|
21
65
|
|
|
22
|
-
|
|
66
|
+
**从 npm(推荐)**
|
|
23
67
|
|
|
24
68
|
```sh
|
|
25
69
|
dsh plugin --profile web add dsh-focus-overlay
|
|
26
70
|
dsh web
|
|
27
71
|
```
|
|
28
72
|
|
|
29
|
-
|
|
73
|
+
**从 GitHub**
|
|
30
74
|
|
|
31
75
|
```sh
|
|
32
76
|
dsh plugin --profile web add github:boogoo619/dsh-focus-overlay
|
|
33
77
|
dsh web
|
|
34
78
|
```
|
|
35
79
|
|
|
36
|
-
>
|
|
80
|
+
> git 安装拉取**源码**并由 `prepare` 脚本现场构建。pnpm ≥10 会先拒绝运行 `prepare`,首次 `add` 失败后,按 `dsh` 提示把包键复制进该 profile 的 `pnpm-workspace.yaml`:
|
|
37
81
|
>
|
|
38
82
|
> ```yaml
|
|
39
83
|
> allowBuilds:
|
|
40
84
|
> dsh-focus-overlay: true
|
|
41
85
|
> ```
|
|
42
86
|
>
|
|
43
|
-
> 然后重新执行 `add
|
|
87
|
+
> 然后重新执行 `add`。**该授权允许本包代码在安装时于你的机器上执行**——请只对可信来源授权,并锁定 commit(`github:boogoo619/dsh-focus-overlay#<sha>`)。
|
|
44
88
|
|
|
45
|
-
|
|
89
|
+
**本地目录 / tarball**
|
|
46
90
|
|
|
47
91
|
```sh
|
|
48
|
-
#
|
|
49
|
-
dsh plugin --profile web add ./dsh-focus-overlay
|
|
50
|
-
|
|
51
|
-
# tarball(npm pack 产物)
|
|
52
|
-
dsh plugin --profile web add ./dsh-focus-overlay-0.1.0.tgz
|
|
92
|
+
dsh plugin --profile web add ./dsh-focus-overlay # 本地目录
|
|
93
|
+
dsh plugin --profile web add ./dsh-focus-overlay-0.1.0.tgz # tarball
|
|
53
94
|
```
|
|
54
95
|
|
|
55
96
|
安装后重启 `dsh web` 生效。
|
|
@@ -58,7 +99,7 @@ dsh plugin --profile web add ./dsh-focus-overlay-0.1.0.tgz
|
|
|
58
99
|
|
|
59
100
|
1. 打开任意会话,点击标题栏操作区的 **「专注」** 按钮。
|
|
60
101
|
2. 进入全屏专注视图:顶部只有一条极窄栏(会话标题 + 退出),正文只显示你与 AI 的对话,工具步骤折叠为摘要行。
|
|
61
|
-
3.
|
|
102
|
+
3. 右侧导航圆点可悬停预览、点击跳转;离开底部时右下出现居中的「↓ 回到最新」。
|
|
62
103
|
4. 按 `Esc` 或点「退出专注」返回原界面,原位置/状态保持不变。
|
|
63
104
|
|
|
64
105
|
## 设置
|
|
@@ -66,33 +107,12 @@ dsh plugin --profile web add ./dsh-focus-overlay-0.1.0.tgz
|
|
|
66
107
|
侧栏「设置 → 专注模式」:
|
|
67
108
|
|
|
68
109
|
| 选项 | 说明 |
|
|
69
|
-
|
|
110
|
+
| --- | --- |
|
|
70
111
|
| 显示右侧导航条 | 开关导航圆点(默认开) |
|
|
71
|
-
| 打开时保留聊天位置 |
|
|
112
|
+
| 打开时保留聊天位置 | 进入时定位到当前阅读位置;关闭则定位到最新(默认开) |
|
|
72
113
|
| 文字区宽度 | 480–1200px 滑块,调整阅读列宽(默认 760px) |
|
|
114
|
+
| 回复结束后自动进入专注模式 | 回复正常完成后:关闭时自动进入并定位到提问,已打开时弹「新回复已生成」;AI 提问 / 审批等待时弹「AI 正在等待你的回复」(默认关) |
|
|
73
115
|
|
|
74
|
-
##
|
|
75
|
-
|
|
76
|
-
```sh
|
|
77
|
-
npm install
|
|
78
|
-
npm run build # 产出 lib/index.mjs(Node half)+ lib/client.js(浏览器 bundle)
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
构建产物 `lib/` 不入库;npm 发布与 git 安装时由 `prepare` 脚本现场构建。
|
|
82
|
-
|
|
83
|
-
## 目录结构
|
|
84
|
-
|
|
85
|
-
```
|
|
86
|
-
src/index.mjs Node half(空 apply)
|
|
87
|
-
src/client/index.ts client apply:注入样式、注册 overlay / 按钮 / 设置页
|
|
88
|
-
src/client/FocusView.tsx 核心组件(MarkdownText/MessageText、图片、摘要、导航条)
|
|
89
|
-
src/client/styles.ts 包内样式(仅 --dsw-* token)
|
|
90
|
-
src/client/settings.ts 偏好(localStorage)
|
|
91
|
-
src/client/locales.ts 中/英词典
|
|
92
|
-
cordis.patch.yml 组合层补丁
|
|
93
|
-
tsdown.config.ts 双 half 构建(Node ESM + 浏览器 __ModuleLoader__ CJS)
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
## License
|
|
116
|
+
## 许可
|
|
97
117
|
|
|
98
118
|
[MIT](./LICENSE)
|
package/lib/client.js
CHANGED
|
@@ -11,7 +11,8 @@ window.__ModuleLoader__.load({
|
|
|
11
11
|
const DEFAULTS = {
|
|
12
12
|
navbar: true,
|
|
13
13
|
scroll: "preserve",
|
|
14
|
-
width: 760
|
|
14
|
+
width: 760,
|
|
15
|
+
autoFocus: false
|
|
15
16
|
};
|
|
16
17
|
function load() {
|
|
17
18
|
try {
|
|
@@ -52,49 +53,7 @@ window.__ModuleLoader__.load({
|
|
|
52
53
|
return p;
|
|
53
54
|
}
|
|
54
55
|
//#endregion
|
|
55
|
-
//#region src/client/
|
|
56
|
-
let focusOn = false;
|
|
57
|
-
let pendingAnchorKey = null;
|
|
58
|
-
const focusListeners = [];
|
|
59
|
-
const focusStore = {
|
|
60
|
-
get: () => focusOn,
|
|
61
|
-
set: (v) => {
|
|
62
|
-
focusOn = !!v;
|
|
63
|
-
for (const l of focusListeners) l();
|
|
64
|
-
},
|
|
65
|
-
subscribe: (l) => {
|
|
66
|
-
focusListeners.push(l);
|
|
67
|
-
return () => {
|
|
68
|
-
const i = focusListeners.indexOf(l);
|
|
69
|
-
if (i >= 0) focusListeners.splice(i, 1);
|
|
70
|
-
};
|
|
71
|
-
},
|
|
72
|
-
captureChatAnchor: () => {
|
|
73
|
-
pendingAnchorKey = null;
|
|
74
|
-
try {
|
|
75
|
-
const scrollport = document.querySelector("[data-conversation-scroll]");
|
|
76
|
-
if (!scrollport) return;
|
|
77
|
-
const vp = scrollport.getBoundingClientRect();
|
|
78
|
-
const rows = scrollport.querySelectorAll("[data-chat-flow-kind=\"user\"], [data-chat-flow-kind=\"steering\"]");
|
|
79
|
-
for (const row of rows) if (row.getBoundingClientRect().bottom > vp.top + 8) {
|
|
80
|
-
pendingAnchorKey = row.dataset.chatAnchorKey || null;
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
} catch {}
|
|
84
|
-
},
|
|
85
|
-
consumeAnchorKey: () => {
|
|
86
|
-
const k = pendingAnchorKey;
|
|
87
|
-
pendingAnchorKey = null;
|
|
88
|
-
return k;
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
function useFocus() {
|
|
92
|
-
const [v, setV] = (0, react.useState)(focusStore.get);
|
|
93
|
-
(0, react.useEffect)(() => focusStore.subscribe(() => setV(focusStore.get)), []);
|
|
94
|
-
return v;
|
|
95
|
-
}
|
|
96
|
-
let bodyEl = null;
|
|
97
|
-
const anchors = {};
|
|
56
|
+
//#region src/client/model.ts
|
|
98
57
|
function flattenText(content) {
|
|
99
58
|
if (!content || !content.length) return "";
|
|
100
59
|
let out = "";
|
|
@@ -285,6 +244,132 @@ window.__ModuleLoader__.load({
|
|
|
285
244
|
}
|
|
286
245
|
return -1;
|
|
287
246
|
}
|
|
247
|
+
/** Seq of the message that started the latest turn: the last `user` node, or a
|
|
248
|
+
* `steering` node as a fallback when no user message exists (e.g. a
|
|
249
|
+
* command-only session). Returns null when neither is present. */
|
|
250
|
+
function lastUserSeq(nodes) {
|
|
251
|
+
let user = null;
|
|
252
|
+
let steering = null;
|
|
253
|
+
for (const n of nodes || []) {
|
|
254
|
+
if (!n || typeof n.seq !== "number") continue;
|
|
255
|
+
if (n.kind === "user") user = n.seq;
|
|
256
|
+
else if (n.kind === "steering") steering = n.seq;
|
|
257
|
+
}
|
|
258
|
+
return user != null ? user : steering;
|
|
259
|
+
}
|
|
260
|
+
/** Index of the last user item in an already-built focus list (toast "View"
|
|
261
|
+
* jump target); -1 when absent. */
|
|
262
|
+
function lastUserIndex(items) {
|
|
263
|
+
for (let i = items.length - 1; i >= 0; i--) if (items[i].kind === "user") return i;
|
|
264
|
+
return -1;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Decide whether an already-settled conversation snapshot (`running === false`)
|
|
268
|
+
* ends in a *normal* completed reply, and which user message started it.
|
|
269
|
+
*
|
|
270
|
+
* The caller owns the `running true → false` edge; this is the pure
|
|
271
|
+
* "is the tail a finished reply?" predicate: it walks backwards to the first
|
|
272
|
+
* turn-terminating node (`assistant` / `turn-error` / `turn-max-tokens`), then
|
|
273
|
+
* accepts only a finalized, non-interrupted assistant node.
|
|
274
|
+
*/
|
|
275
|
+
function detectSettledCompletion(snap) {
|
|
276
|
+
const nodes = snap && snap.nodes ? snap.nodes : [];
|
|
277
|
+
let terminal = null;
|
|
278
|
+
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
279
|
+
const n = nodes[i];
|
|
280
|
+
if (!n) continue;
|
|
281
|
+
if (n.kind === "assistant" || n.kind === "turn-error" || n.kind === "turn-max-tokens") {
|
|
282
|
+
terminal = n;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!terminal || terminal.kind !== "assistant" || terminal.interrupted) return {
|
|
287
|
+
completed: false,
|
|
288
|
+
anchorSeq: null
|
|
289
|
+
};
|
|
290
|
+
return {
|
|
291
|
+
completed: true,
|
|
292
|
+
anchorSeq: lastUserSeq(nodes)
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/** True while the session has a pending interaction — the AI is blocked waiting
|
|
296
|
+
* for the user (to answer a question or approve an action). It stays true for
|
|
297
|
+
* the whole wait (`running` remains true throughout) and clears as soon as the
|
|
298
|
+
* user answers, so it is the live signal for "questions still unanswered". */
|
|
299
|
+
function hasPendingInteraction(snap) {
|
|
300
|
+
const pending = snap && snap.pending;
|
|
301
|
+
return Array.isArray(pending) && pending.length > 0;
|
|
302
|
+
}
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/client/FocusView.tsx
|
|
305
|
+
let focusOn = false;
|
|
306
|
+
let pendingAnchorKey = null;
|
|
307
|
+
let pendingAutoSeq = null;
|
|
308
|
+
let donePing = 0;
|
|
309
|
+
const focusListeners = [];
|
|
310
|
+
const notify = () => {
|
|
311
|
+
for (const l of focusListeners) l();
|
|
312
|
+
};
|
|
313
|
+
const focusStore = {
|
|
314
|
+
get: () => focusOn,
|
|
315
|
+
set: (v) => {
|
|
316
|
+
focusOn = !!v;
|
|
317
|
+
notify();
|
|
318
|
+
},
|
|
319
|
+
subscribe: (l) => {
|
|
320
|
+
focusListeners.push(l);
|
|
321
|
+
return () => {
|
|
322
|
+
const i = focusListeners.indexOf(l);
|
|
323
|
+
if (i >= 0) focusListeners.splice(i, 1);
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
captureChatAnchor: () => {
|
|
327
|
+
pendingAnchorKey = null;
|
|
328
|
+
try {
|
|
329
|
+
const scrollport = document.querySelector("[data-conversation-scroll]");
|
|
330
|
+
if (!scrollport) return;
|
|
331
|
+
const vp = scrollport.getBoundingClientRect();
|
|
332
|
+
const rows = scrollport.querySelectorAll("[data-chat-flow-kind=\"user\"], [data-chat-flow-kind=\"steering\"]");
|
|
333
|
+
for (const row of rows) if (row.getBoundingClientRect().bottom > vp.top + 8) {
|
|
334
|
+
pendingAnchorKey = row.dataset.chatAnchorKey || null;
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
} catch {}
|
|
338
|
+
},
|
|
339
|
+
consumeAnchorKey: () => {
|
|
340
|
+
const k = pendingAnchorKey;
|
|
341
|
+
pendingAnchorKey = null;
|
|
342
|
+
return k;
|
|
343
|
+
},
|
|
344
|
+
setAutoAnchor: (seq) => {
|
|
345
|
+
pendingAutoSeq = seq;
|
|
346
|
+
},
|
|
347
|
+
consumeAutoSeq: () => {
|
|
348
|
+
const s = pendingAutoSeq;
|
|
349
|
+
pendingAutoSeq = null;
|
|
350
|
+
return s;
|
|
351
|
+
},
|
|
352
|
+
getDonePing: () => donePing,
|
|
353
|
+
notifyDone: () => {
|
|
354
|
+
donePing++;
|
|
355
|
+
notify();
|
|
356
|
+
},
|
|
357
|
+
clearDonePing: () => {
|
|
358
|
+
donePing = 0;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
function useFocus() {
|
|
362
|
+
const [v, setV] = (0, react.useState)(focusStore.get);
|
|
363
|
+
(0, react.useEffect)(() => focusStore.subscribe(() => setV(focusStore.get)), []);
|
|
364
|
+
return v;
|
|
365
|
+
}
|
|
366
|
+
function useDonePing() {
|
|
367
|
+
const [v, setV] = (0, react.useState)(focusStore.getDonePing);
|
|
368
|
+
(0, react.useEffect)(() => focusStore.subscribe(() => setV(focusStore.getDonePing())), []);
|
|
369
|
+
return v;
|
|
370
|
+
}
|
|
371
|
+
let bodyEl = null;
|
|
372
|
+
const anchors = {};
|
|
288
373
|
function useSessionSnapshot(sessions, sessionId) {
|
|
289
374
|
const [snap, setSnap] = (0, react.useState)(() => {
|
|
290
375
|
if (!sessionId || !sessions) return null;
|
|
@@ -397,6 +482,21 @@ window.__ModuleLoader__.load({
|
|
|
397
482
|
});
|
|
398
483
|
};
|
|
399
484
|
(0, react.useEffect)(() => {
|
|
485
|
+
const autoSeq = focusStore.consumeAutoSeq();
|
|
486
|
+
if (autoSeq != null) {
|
|
487
|
+
const idx = findSeqIndex(items, autoSeq);
|
|
488
|
+
if (idx >= 0) {
|
|
489
|
+
scrollToKey("fm-" + idx, false);
|
|
490
|
+
setActiveKey("fm-" + idx);
|
|
491
|
+
} else if (bodyEl) {
|
|
492
|
+
bodyEl.scrollTo({
|
|
493
|
+
top: bodyEl.scrollHeight,
|
|
494
|
+
behavior: "auto"
|
|
495
|
+
});
|
|
496
|
+
setActiveKey(navKeys.length ? navKeys[navKeys.length - 1] : null);
|
|
497
|
+
}
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
400
500
|
const anchorKey = focusStore.consumeAnchorKey();
|
|
401
501
|
const seq = resolveAnchorSeq(snap && snap.chat, anchorKey);
|
|
402
502
|
const idx = seq != null ? findSeqIndex(items, seq) : -1;
|
|
@@ -509,17 +609,22 @@ window.__ModuleLoader__.load({
|
|
|
509
609
|
children: kids
|
|
510
610
|
});
|
|
511
611
|
}
|
|
612
|
+
const activeIndex = activeKey ? navKeys.indexOf(activeKey) : -1;
|
|
613
|
+
const navHeight = (navKeys.length - 1) * 14 + 22;
|
|
614
|
+
const dotTop = (i) => i * 14 + 11 + (i < activeIndex ? -7 : i > activeIndex ? 7 : 0);
|
|
512
615
|
const nav = prefs.navbar && navKeys.length >= 2 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
513
616
|
className: "fm-nav",
|
|
514
|
-
|
|
617
|
+
style: { height: navHeight },
|
|
618
|
+
children: navKeys.map((k, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
515
619
|
type: "button",
|
|
516
620
|
"aria-label": navPreviews[k],
|
|
517
621
|
className: "fm-nav-dot" + (activeKey === k ? " fm-nav-dot-active" : ""),
|
|
622
|
+
style: { top: dotTop(i) },
|
|
518
623
|
onClick: () => scrollToKey(k, true),
|
|
519
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
624
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fm-nav-dot-core" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
520
625
|
className: "fm-nav-tip",
|
|
521
626
|
children: navPreviews[k]
|
|
522
|
-
})
|
|
627
|
+
})]
|
|
523
628
|
}, k))
|
|
524
629
|
}) : null;
|
|
525
630
|
const toBottom = !atBottom ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -533,6 +638,57 @@ window.__ModuleLoader__.load({
|
|
|
533
638
|
children: "↓"
|
|
534
639
|
})
|
|
535
640
|
}) : null;
|
|
641
|
+
const waiting = hasPendingInteraction(snap);
|
|
642
|
+
const donePing = useDonePing();
|
|
643
|
+
const [showDoneNotice, setShowDoneNotice] = (0, react.useState)(false);
|
|
644
|
+
(0, react.useEffect)(() => {
|
|
645
|
+
if (!donePing) return;
|
|
646
|
+
setShowDoneNotice(true);
|
|
647
|
+
const id = setTimeout(() => setShowDoneNotice(false), 6e3);
|
|
648
|
+
return () => clearTimeout(id);
|
|
649
|
+
}, [donePing]);
|
|
650
|
+
(0, react.useEffect)(() => () => focusStore.clearDonePing(), []);
|
|
651
|
+
const jumpToLatestQuestion = () => {
|
|
652
|
+
const idx = lastUserIndex(items);
|
|
653
|
+
if (idx >= 0) {
|
|
654
|
+
scrollToKey("fm-" + idx, true);
|
|
655
|
+
setActiveKey("fm-" + idx);
|
|
656
|
+
} else scrollToBottom();
|
|
657
|
+
};
|
|
658
|
+
const replyNotice = waiting ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
659
|
+
className: "fm-reply-toast",
|
|
660
|
+
children: [
|
|
661
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fm-reply-toast-dot" }),
|
|
662
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
663
|
+
className: "fm-reply-toast-text",
|
|
664
|
+
children: t("reply.waiting")
|
|
665
|
+
}),
|
|
666
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
667
|
+
variant: "primary",
|
|
668
|
+
size: "sm",
|
|
669
|
+
onClick: () => focusStore.set(false),
|
|
670
|
+
children: t("reply.respond")
|
|
671
|
+
})
|
|
672
|
+
]
|
|
673
|
+
}) : showDoneNotice ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
674
|
+
className: "fm-reply-toast",
|
|
675
|
+
children: [
|
|
676
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fm-reply-toast-dot" }),
|
|
677
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
678
|
+
className: "fm-reply-toast-text",
|
|
679
|
+
children: t("reply.ready")
|
|
680
|
+
}),
|
|
681
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
682
|
+
variant: "primary",
|
|
683
|
+
size: "sm",
|
|
684
|
+
onClick: () => {
|
|
685
|
+
setShowDoneNotice(false);
|
|
686
|
+
jumpToLatestQuestion();
|
|
687
|
+
},
|
|
688
|
+
children: t("reply.view")
|
|
689
|
+
})
|
|
690
|
+
]
|
|
691
|
+
}) : null;
|
|
536
692
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
537
693
|
className: "fm-overlay",
|
|
538
694
|
tabIndex: -1,
|
|
@@ -562,7 +718,8 @@ window.__ModuleLoader__.load({
|
|
|
562
718
|
children: body
|
|
563
719
|
}),
|
|
564
720
|
nav,
|
|
565
|
-
toBottom
|
|
721
|
+
toBottom,
|
|
722
|
+
replyNotice
|
|
566
723
|
]
|
|
567
724
|
});
|
|
568
725
|
}
|
|
@@ -603,6 +760,14 @@ window.__ModuleLoader__.load({
|
|
|
603
760
|
onChange: (e) => prefsStore.update({ scroll: e.target.checked ? "preserve" : "bottom" })
|
|
604
761
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.scrollPreserve") })]
|
|
605
762
|
}),
|
|
763
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
764
|
+
className: "fm-setting-row",
|
|
765
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
766
|
+
type: "checkbox",
|
|
767
|
+
checked: prefs.autoFocus,
|
|
768
|
+
onChange: (e) => prefsStore.update({ autoFocus: e.target.checked })
|
|
769
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.autoFocus") })]
|
|
770
|
+
}),
|
|
606
771
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
607
772
|
className: "fm-setting-row",
|
|
608
773
|
children: [
|
|
@@ -640,14 +805,21 @@ window.__ModuleLoader__.load({
|
|
|
640
805
|
.fm-error{color:var(--dsw-alias-state-error-primary,#d23);font-size:13px;white-space:pre-wrap}
|
|
641
806
|
.fm-running{color:var(--dsw-alias-label-secondary,#888);font-size:13px;margin:6px 0 16px}
|
|
642
807
|
.fm-empty{color:var(--dsw-alias-label-secondary,#888);padding:48px 16px;text-align:center}
|
|
643
|
-
.fm-nav{position:absolute;right:20px;top:50%;transform:translateY(-50%);display:
|
|
644
|
-
.fm-nav
|
|
645
|
-
.fm-nav-dot
|
|
646
|
-
.fm-nav-dot
|
|
647
|
-
.fm-nav-
|
|
808
|
+
.fm-nav{position:absolute;right:20px;top:50%;transform:translateY(-50%);display:block;width:16px;z-index:2;pointer-events:auto;transition:transform .18s ease}
|
|
809
|
+
.fm-nav:hover{transform:translateY(-50%) translateX(-6px)}
|
|
810
|
+
.fm-nav-dot{position:absolute;left:50%;transform:translate(-50%,-50%);cursor:pointer;background:transparent;border:0;padding:0;width:16px;height:auto;display:flex;align-items:center;justify-content:center;transition:top .22s ease}
|
|
811
|
+
.fm-nav-dot::after{content:'';position:absolute;inset:-5px -8px}
|
|
812
|
+
.fm-nav-dot-core{display:block;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-label-secondary,#999);opacity:.45;transition:opacity .15s ease,background .15s ease,height .15s ease,transform .18s ease}
|
|
813
|
+
.fm-nav:hover .fm-nav-dot:not(.fm-nav-dot-active) .fm-nav-dot-core{transform:scale(1.15);opacity:.9}
|
|
814
|
+
.fm-nav-dot:not(.fm-nav-dot-active):hover .fm-nav-dot-core{background:var(--dsw-static-deepseek-500,#4176e6);opacity:1;transform:scale(1.5)}
|
|
815
|
+
.fm-nav-dot-active .fm-nav-dot-core{background:var(--dsw-static-deepseek-500,#4176e6);height:22px;opacity:1}
|
|
816
|
+
.fm-nav-tip{position:absolute;right:calc(100% + 8px);top:50%;transform:translateY(-50%);display:none;width:260px;box-sizing:border-box;white-space:normal;overflow-wrap:break-word;word-break:break-word;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.12));border-radius:8px;padding:8px 10px;font-size:12px;line-height:1.5;box-shadow:0 4px 16px rgba(0,0,0,.12);pointer-events:none;text-align:left}
|
|
648
817
|
.fm-nav-dot:hover .fm-nav-tip{display:block}
|
|
649
818
|
.fm-tobottom-wrap{position:absolute;left:50%;transform:translateX(-50%);bottom:18px;z-index:2;pointer-events:none}
|
|
650
819
|
.fm-tobottom{pointer-events:auto;border-radius:100px;min-width:34px;height:34px;padding:0 10px}
|
|
820
|
+
.fm-reply-toast{position:absolute;left:50%;transform:translateX(-50%);bottom:64px;z-index:2;display:flex;align-items:center;gap:10px;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.12));border-radius:100px;padding:8px 8px 8px 16px;box-shadow:0 4px 16px rgba(0,0,0,.12);font-size:13px}
|
|
821
|
+
.fm-reply-toast-dot{width:8px;height:8px;border-radius:999px;background:var(--dsw-static-deepseek-500,#4176e6);flex:0 0 auto}
|
|
822
|
+
.fm-reply-toast-text{white-space:nowrap}
|
|
651
823
|
.fm-settings{display:flex;flex-direction:column;gap:16px;max-width:560px;padding:8px 0}
|
|
652
824
|
.fm-setting-row{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer;user-select:none}
|
|
653
825
|
.fm-setting-row input[type="checkbox"]{width:16px;height:16px;cursor:pointer}
|
|
@@ -664,10 +836,15 @@ window.__ModuleLoader__.load({
|
|
|
664
836
|
"settings.label": "专注模式",
|
|
665
837
|
"settings.navbar": "显示右侧导航条",
|
|
666
838
|
"settings.scrollPreserve": "打开时保留聊天位置",
|
|
839
|
+
"settings.autoFocus": "回复结束后自动进入专注模式",
|
|
667
840
|
"settings.width": "文字区宽度",
|
|
668
841
|
"empty": "暂无打开的会话",
|
|
669
842
|
"loading": "正在读取会话…",
|
|
670
843
|
"running": "· 正在工作 …",
|
|
844
|
+
"reply.ready": "新回复已生成",
|
|
845
|
+
"reply.view": "查看",
|
|
846
|
+
"reply.waiting": "AI 正在等待你的回复",
|
|
847
|
+
"reply.respond": "去回复",
|
|
671
848
|
"sum.context": "载入了 {n} 项上下文",
|
|
672
849
|
"sum.commands": "运行了 {n} 个命令",
|
|
673
850
|
"sum.edits": "编辑了 {n} 个文件",
|
|
@@ -692,10 +869,15 @@ window.__ModuleLoader__.load({
|
|
|
692
869
|
"settings.label": "Focus Mode",
|
|
693
870
|
"settings.navbar": "Show right-side navbar",
|
|
694
871
|
"settings.scrollPreserve": "Preserve chat position on open",
|
|
872
|
+
"settings.autoFocus": "Auto-enter focus mode when a reply completes",
|
|
695
873
|
"settings.width": "Text width",
|
|
696
874
|
"empty": "No open session",
|
|
697
875
|
"loading": "Reading session…",
|
|
698
876
|
"running": "· Working …",
|
|
877
|
+
"reply.ready": "New reply ready",
|
|
878
|
+
"reply.view": "View",
|
|
879
|
+
"reply.waiting": "AI is waiting for your reply",
|
|
880
|
+
"reply.respond": "Reply",
|
|
699
881
|
"sum.context": "loaded {n} context items",
|
|
700
882
|
"sum.commands": "ran {n} commands",
|
|
701
883
|
"sum.edits": "edited {n} files",
|
|
@@ -750,6 +932,52 @@ window.__ModuleLoader__.load({
|
|
|
750
932
|
const workspaces = ctx.workspaces;
|
|
751
933
|
const conversation = ctx.get("conversation");
|
|
752
934
|
const chatFileMentions = ctx.get("chatFileMentions");
|
|
935
|
+
ctx.effect(() => {
|
|
936
|
+
let currentId = void 0;
|
|
937
|
+
let unsubSession = null;
|
|
938
|
+
let prevRunning = false;
|
|
939
|
+
const watch = (id) => {
|
|
940
|
+
if (unsubSession) {
|
|
941
|
+
unsubSession();
|
|
942
|
+
unsubSession = null;
|
|
943
|
+
}
|
|
944
|
+
prevRunning = false;
|
|
945
|
+
if (id == null) return;
|
|
946
|
+
const binding = sessions.binding(id);
|
|
947
|
+
if (!binding) return;
|
|
948
|
+
const face = binding.session;
|
|
949
|
+
const onSnap = () => {
|
|
950
|
+
const snap = face.getSnapshot();
|
|
951
|
+
if (!snap) return;
|
|
952
|
+
const running = !!snap.running;
|
|
953
|
+
if (prevRunning && !running) {
|
|
954
|
+
const outcome = detectSettledCompletion(snap);
|
|
955
|
+
if (outcome.completed && prefsStore.get().autoFocus) if (focusStore.get()) focusStore.notifyDone();
|
|
956
|
+
else {
|
|
957
|
+
focusStore.setAutoAnchor(outcome.anchorSeq);
|
|
958
|
+
focusStore.set(true);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
prevRunning = running;
|
|
962
|
+
};
|
|
963
|
+
onSnap();
|
|
964
|
+
unsubSession = face.subscribe(onSnap);
|
|
965
|
+
};
|
|
966
|
+
const onList = () => {
|
|
967
|
+
const ls = sessions.list.getSnapshot();
|
|
968
|
+
const next = ls ? ls.current : void 0;
|
|
969
|
+
if (next !== currentId) {
|
|
970
|
+
currentId = next;
|
|
971
|
+
watch(next);
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
onList();
|
|
975
|
+
const unsubList = sessions.list.subscribe(onList);
|
|
976
|
+
return () => {
|
|
977
|
+
if (unsubList) unsubList();
|
|
978
|
+
if (unsubSession) unsubSession();
|
|
979
|
+
};
|
|
980
|
+
});
|
|
753
981
|
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
754
982
|
name: "shell.overlay",
|
|
755
983
|
id: "focus-mode-overlay",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-focus-overlay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Focus Mode for the dsh web GUI: a full-screen reading overlay that hides chrome and folds the AI tool-call flow into summaries, reusing the official Markdown / image primitives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.mjs",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "tsdown --config ./tsdown.config.ts",
|
|
40
|
-
"prepare": "npm run build"
|
|
40
|
+
"prepare": "npm run build",
|
|
41
|
+
"test": "vitest run"
|
|
41
42
|
},
|
|
42
43
|
"keywords": [
|
|
43
44
|
"dsh",
|
|
@@ -47,7 +48,8 @@
|
|
|
47
48
|
"bundle"
|
|
48
49
|
],
|
|
49
50
|
"devDependencies": {
|
|
50
|
-
"tsdown": "0.22.2"
|
|
51
|
+
"tsdown": "0.22.2",
|
|
52
|
+
"vitest": "^4.1.10"
|
|
51
53
|
},
|
|
52
54
|
"repository": {
|
|
53
55
|
"type": "git",
|