dsh-focus-overlay 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 boogoo619
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # dsh-focus-overlay
2
+
3
+ 为 DeepSeek Harness(DSH)Web GUI 提供的**专注模式**插件:一键进入全屏阅读视图,隐藏标题区与输入区,把 AI 的工具调用流程折叠成一句话摘要,只保留"你与 AI 的对话"本身。文本与图片渲染直接复用官方的 `@deepseek-ai/dsh-client-ui-primitives` 原语,与聊天视图 1:1 一致。
4
+
5
+ 这是一个 **bundle 插件**(区别于会话内临时动态插件),能 `import` 官方 Markdown 渲染器、解析会话授权图片,并持久化设置。
6
+
7
+ ## 特性
8
+
9
+ - **全屏专注遮罩**:通过 `shell.overlay` 注册(纯增量,绝不替换/遮挡自带 UI 的数据),覆盖整个界面,纵向空间全留给对话。
10
+ - **官方渲染**:AI 文本走官方 `MarkdownText`(GFM + 代码高亮 + TeX),用户消息走 `MessageText`,图片走 `conversation.resolveImage`。
11
+ - **工具调用折叠**:工具调用 / 命令 / 上下文注入折叠成分类摘要行("运行了 2 个命令,编辑了 3 个文件,读取了 5 个文件…")。
12
+ - **精确保留滚动位置**:进入专注时定位到聊天视图中当前正在阅读的消息,而非从头开始。
13
+ - **右侧节点导航条**:每个 user 消息一个圆点,激活药丸跟随滚动、悬停预览、点击跳转。
14
+ - **回到最新**:离开底部时显示居中的"↓ 回到最新"悬浮按钮。
15
+ - **i18n**:中 / 英文案,跟随界面语言。
16
+ - **设置页**:显示导航条、打开时保留位置、文字区宽度(可拖滑块),持久化到 `localStorage`。
17
+
18
+ ## 安装
19
+
20
+ 需要 `dsh` CLI(`>= 0.1.0-rc.5`)。
21
+
22
+ ### 从 npm
23
+
24
+ ```sh
25
+ dsh plugin --profile web add dsh-focus-overlay
26
+ dsh web
27
+ ```
28
+
29
+ ### 从 GitHub
30
+
31
+ ```sh
32
+ dsh plugin --profile web add github:boogoo619/dsh-focus-overlay
33
+ dsh web
34
+ ```
35
+
36
+ > 从 git 安装会拉取**源码**并由 `prepare` 脚本现场构建。pnpm ≥10 会先拒绝运行 `prepare`,首次 `add` 失败后,按 `dsh` 提示把包键复制进该 profile 的 `pnpm-workspace.yaml` 授权:
37
+ >
38
+ > ```yaml
39
+ > allowBuilds:
40
+ > dsh-focus-overlay: true
41
+ > ```
42
+ >
43
+ > 然后重新执行 `add`。**这项授权意味着允许本包代码在你的机器上于安装时执行**——请只对可信来源授权,并用 commit 锁定(`github:boogoo619/dsh-focus-overlay#<sha>`)。
44
+
45
+ ### 从本地目录 / tarball
46
+
47
+ ```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
53
+ ```
54
+
55
+ 安装后重启 `dsh web` 生效。
56
+
57
+ ## 使用
58
+
59
+ 1. 打开任意会话,点击标题栏操作区的 **「专注」** 按钮。
60
+ 2. 进入全屏专注视图:顶部只有一条极窄栏(会话标题 + 退出),正文只显示你与 AI 的对话,工具步骤折叠为摘要行。
61
+ 3. 右侧的导航圆点可悬停预览、点击跳转;离开底部时右下出现居中的"↓ 回到最新"。
62
+ 4. 按 `Esc` 或点「退出专注」返回原界面,原位置/状态保持不变。
63
+
64
+ ## 设置
65
+
66
+ 侧栏「设置 → 专注模式」:
67
+
68
+ | 选项 | 说明 |
69
+ |---|---|
70
+ | 显示右侧导航条 | 开关导航圆点(默认开) |
71
+ | 打开时保留聊天位置 | 进入专注时定位到当前阅读位置,关闭则定位到最新(默认开) |
72
+ | 文字区宽度 | 480–1200px 滑块,调整阅读列宽(默认 760px) |
73
+
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
97
+
98
+ [MIT](./LICENSE)
@@ -0,0 +1,4 @@
1
+ # dsh-focus-overlay bundle: mount the Node half (empty apply — behaviour is all in the client bundle).
2
+ - insert:
3
+ - id: dsh-focus-overlay
4
+ name: 'dsh-focus-overlay'
package/lib/client.js ADDED
@@ -0,0 +1,781 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-focus-overlay",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ module.exports;
6
+ let react = require("react");
7
+ let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
8
+ let react_jsx_runtime = require("react/jsx-runtime");
9
+ //#region src/client/settings.ts
10
+ const KEY = "dsh-focus-overlay:prefs";
11
+ const DEFAULTS = {
12
+ navbar: true,
13
+ scroll: "preserve",
14
+ width: 760
15
+ };
16
+ function load() {
17
+ try {
18
+ const raw = localStorage.getItem(KEY);
19
+ if (raw) return {
20
+ ...DEFAULTS,
21
+ ...JSON.parse(raw)
22
+ };
23
+ } catch {}
24
+ return { ...DEFAULTS };
25
+ }
26
+ let prefs = load();
27
+ const listeners = [];
28
+ const prefsStore = {
29
+ get: () => prefs,
30
+ set: (next) => {
31
+ prefs = next;
32
+ try {
33
+ localStorage.setItem(KEY, JSON.stringify(next));
34
+ } catch {}
35
+ for (const l of listeners) l();
36
+ },
37
+ update: (patch) => prefsStore.set({
38
+ ...prefs,
39
+ ...patch
40
+ }),
41
+ subscribe: (l) => {
42
+ listeners.push(l);
43
+ return () => {
44
+ const i = listeners.indexOf(l);
45
+ if (i >= 0) listeners.splice(i, 1);
46
+ };
47
+ }
48
+ };
49
+ function usePrefs() {
50
+ const [p, setP] = (0, react.useState)(prefsStore.get);
51
+ (0, react.useEffect)(() => prefsStore.subscribe(() => setP(prefsStore.get)), []);
52
+ return p;
53
+ }
54
+ //#endregion
55
+ //#region src/client/FocusView.tsx
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 = {};
98
+ function flattenText(content) {
99
+ if (!content || !content.length) return "";
100
+ let out = "";
101
+ for (const b of content) if (b && b.type === "text" && typeof b.text === "string") out += b.text;
102
+ return out;
103
+ }
104
+ const METRIC = {
105
+ bash: "commands",
106
+ pwsh: "commands",
107
+ sh: "commands",
108
+ cmd: "commands",
109
+ terminal: "commands",
110
+ shell: "commands",
111
+ write: "edits",
112
+ save: "edits",
113
+ edit: "edits",
114
+ replace: "edits",
115
+ patch: "edits",
116
+ apply_patch: "edits",
117
+ str_replace_editor: "edits",
118
+ web_search: "searches",
119
+ grep: "searches",
120
+ search: "searches",
121
+ read: "files",
122
+ read_image: "files",
123
+ glob: "dirs",
124
+ subagent: "subagents",
125
+ subagent_fork: "subagents",
126
+ todo_write: "todos",
127
+ create_goal: "goals",
128
+ update_goal: "goals",
129
+ get_goal: "goals",
130
+ workflow: "workflows",
131
+ ralph: "workflows",
132
+ skill: "skills",
133
+ ask_user_question: "questions",
134
+ plan: "plans",
135
+ job_output: "jobs",
136
+ job_kill: "jobs",
137
+ job_list: "jobs"
138
+ };
139
+ const METRIC_ORDER = [
140
+ "context",
141
+ "commands",
142
+ "edits",
143
+ "searches",
144
+ "files",
145
+ "dirs",
146
+ "subagents",
147
+ "todos",
148
+ "goals",
149
+ "workflows",
150
+ "skills",
151
+ "questions",
152
+ "plans",
153
+ "jobs",
154
+ "others"
155
+ ];
156
+ const METRIC_KEY = {
157
+ context: "sum.context",
158
+ commands: "sum.commands",
159
+ edits: "sum.edits",
160
+ searches: "sum.searches",
161
+ files: "sum.files",
162
+ dirs: "sum.dirs",
163
+ subagents: "sum.subagents",
164
+ todos: "sum.todos",
165
+ goals: "sum.goals",
166
+ workflows: "sum.workflows",
167
+ skills: "sum.skills",
168
+ questions: "sum.questions",
169
+ plans: "sum.plans",
170
+ jobs: "sum.jobs",
171
+ others: "sum.others"
172
+ };
173
+ function addMetric(metrics, name) {
174
+ const fam = METRIC[name] || "others";
175
+ metrics[fam] = (metrics[fam] || 0) + 1;
176
+ }
177
+ function summarySegments(t, metrics) {
178
+ const segs = [];
179
+ for (const fam of METRIC_ORDER) {
180
+ const n = metrics[fam];
181
+ if (!n) continue;
182
+ const key = METRIC_KEY[fam];
183
+ segs.push(key === "sum.todos" || key === "sum.goals" ? t(key) : t(key, { n }));
184
+ }
185
+ return segs;
186
+ }
187
+ function hasAssistantContent(blocks) {
188
+ for (const b of blocks || []) if (b && (b.kind === "text" || b.kind === "image")) return true;
189
+ return false;
190
+ }
191
+ function buildItems(nodes, runningCalls, t) {
192
+ const items = [];
193
+ let metrics = {};
194
+ const flush = () => {
195
+ const segs = summarySegments(t, metrics);
196
+ if (segs.length) items.push({
197
+ kind: "hidden",
198
+ text: segs.join(",")
199
+ });
200
+ metrics = {};
201
+ };
202
+ const accountNode = (n) => {
203
+ if (n.kind === "tool-result") addMetric(metrics, n.call && n.call.name ? n.call.name : "");
204
+ else if (n.kind === "context" || n.kind === "compaction") metrics.context = (metrics.context || 0) + 1;
205
+ else if (n.kind === "command") metrics.commands = (metrics.commands || 0) + 1;
206
+ else metrics.others = (metrics.others || 0) + 1;
207
+ };
208
+ for (const n of nodes) {
209
+ if (!n) continue;
210
+ switch (n.kind) {
211
+ case "user": {
212
+ const text = flattenText(n.content);
213
+ if (text.trim() !== "") {
214
+ flush();
215
+ items.push({
216
+ kind: "user",
217
+ text,
218
+ seq: n.seq
219
+ });
220
+ }
221
+ break;
222
+ }
223
+ case "steering": {
224
+ const text = flattenText(n.content);
225
+ if (text.trim() !== "") {
226
+ flush();
227
+ items.push({
228
+ kind: "steering",
229
+ text,
230
+ seq: n.seq
231
+ });
232
+ }
233
+ break;
234
+ }
235
+ case "assistant":
236
+ if (hasAssistantContent(n.blocks)) {
237
+ flush();
238
+ items.push({
239
+ kind: "assistant",
240
+ blocks: n.blocks,
241
+ seq: n.seq,
242
+ turn: n.turn,
243
+ step: n.step
244
+ });
245
+ }
246
+ break;
247
+ case "turn-error":
248
+ flush();
249
+ items.push({
250
+ kind: "error",
251
+ text: n.message || "turn-error"
252
+ });
253
+ break;
254
+ case "turn-max-tokens":
255
+ flush();
256
+ items.push({
257
+ kind: "error",
258
+ text: "turn-max-tokens"
259
+ });
260
+ break;
261
+ default:
262
+ accountNode(n);
263
+ break;
264
+ }
265
+ }
266
+ if (runningCalls && runningCalls.length) for (const rc of runningCalls) addMetric(metrics, rc && rc.name ? rc.name : "");
267
+ flush();
268
+ return items;
269
+ }
270
+ function resolveAnchorSeq(chat, key) {
271
+ if (!key || !chat || !chat.nodes) return null;
272
+ try {
273
+ const node = chat.nodes.get(key);
274
+ if (!node) return null;
275
+ const d = node.data || node.node || node;
276
+ return d && typeof d.seq === "number" ? d.seq : null;
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+ function findSeqIndex(items, seq) {
282
+ for (let i = 0; i < items.length; i++) {
283
+ const it = items[i];
284
+ if ((it.kind === "user" || it.kind === "steering") && it.seq === seq) return i;
285
+ }
286
+ return -1;
287
+ }
288
+ function useSessionSnapshot(sessions, sessionId) {
289
+ const [snap, setSnap] = (0, react.useState)(() => {
290
+ if (!sessionId || !sessions) return null;
291
+ const b = sessions.binding(sessionId);
292
+ return b ? b.session.getSnapshot() : null;
293
+ });
294
+ (0, react.useEffect)(() => {
295
+ if (!sessionId || !sessions) {
296
+ setSnap(null);
297
+ return;
298
+ }
299
+ const b = sessions.binding(sessionId);
300
+ if (!b) {
301
+ setSnap(null);
302
+ return;
303
+ }
304
+ setSnap(b.session.getSnapshot());
305
+ return b.session.subscribe(() => setSnap(b.session.getSnapshot()));
306
+ }, [sessions, sessionId]);
307
+ return snap;
308
+ }
309
+ function SessionImage({ attachment, loadImage }) {
310
+ const [src, setSrc] = (0, react.useState)(null);
311
+ (0, react.useEffect)(() => {
312
+ let alive = true;
313
+ loadImage(attachment).then((u) => {
314
+ if (alive) setSrc(u);
315
+ }).catch(() => {});
316
+ return () => {
317
+ alive = false;
318
+ };
319
+ }, [attachment, loadImage]);
320
+ if (!src) return null;
321
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
322
+ className: "fm-image",
323
+ src,
324
+ alt: ""
325
+ });
326
+ }
327
+ function AssistantItem({ blocks, loadImage, fileMentions }) {
328
+ const text = (blocks || []).filter((b) => b && b.kind === "text").map((b) => b.text).join("");
329
+ const images = (blocks || []).filter((b) => b && b.kind === "image");
330
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
331
+ className: "fm-msg fm-assistant",
332
+ children: [text ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MarkdownText, {
333
+ text,
334
+ fileMentions
335
+ }) : null, images.map((b, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionImage, {
336
+ attachment: b.attachment,
337
+ loadImage
338
+ }, i))]
339
+ });
340
+ }
341
+ function FocusContent(props) {
342
+ const { useSessions, sessions, workspaces, conversation, chatFileMentions, t } = props;
343
+ const prefs = usePrefs();
344
+ const listState = useSessions((s) => s);
345
+ const currentId = listState ? listState.current : void 0;
346
+ const snap = useSessionSnapshot(sessions, currentId);
347
+ const items = (0, react.useMemo)(() => snap ? buildItems(snap.nodes || [], snap.runningCalls, t) : [], [snap, t]);
348
+ const navKeys = [];
349
+ const navPreviews = {};
350
+ for (let i = 0; i < items.length; i++) {
351
+ const it = items[i];
352
+ if (it.kind === "user" || it.kind === "steering") {
353
+ const k = "fm-" + i;
354
+ navKeys.push(k);
355
+ navPreviews[k] = it.text.replace(/\s+/g, " ").trim().slice(0, 80);
356
+ }
357
+ }
358
+ const [activeKey, setActiveKey] = (0, react.useState)(navKeys.length ? navKeys[navKeys.length - 1] : null);
359
+ const [atBottom, setAtBottom] = (0, react.useState)(true);
360
+ const rafId = (0, react.useRef)(null);
361
+ const computeActive = () => {
362
+ if (!bodyEl) return null;
363
+ let cur = null;
364
+ for (const k of navKeys) {
365
+ const el = anchors[k];
366
+ if (!el) continue;
367
+ if (el.getBoundingClientRect().top - bodyEl.getBoundingClientRect().top <= 96) cur = k;
368
+ else break;
369
+ }
370
+ return cur;
371
+ };
372
+ const isNearBottom = () => {
373
+ if (!bodyEl) return true;
374
+ return bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight < 48;
375
+ };
376
+ const handleScroll = () => {
377
+ if (rafId.current !== null) return;
378
+ rafId.current = requestAnimationFrame(() => {
379
+ rafId.current = null;
380
+ setActiveKey(computeActive());
381
+ setAtBottom(isNearBottom());
382
+ });
383
+ };
384
+ const scrollToKey = (key, smooth) => {
385
+ const el = anchors[key];
386
+ if (!el || !bodyEl) return;
387
+ const top = el.getBoundingClientRect().top - bodyEl.getBoundingClientRect().top + bodyEl.scrollTop;
388
+ bodyEl.scrollTo({
389
+ top: top - 16,
390
+ behavior: smooth ? "smooth" : "auto"
391
+ });
392
+ };
393
+ const scrollToBottom = () => {
394
+ if (bodyEl) bodyEl.scrollTo({
395
+ top: bodyEl.scrollHeight,
396
+ behavior: "smooth"
397
+ });
398
+ };
399
+ (0, react.useEffect)(() => {
400
+ const anchorKey = focusStore.consumeAnchorKey();
401
+ const seq = resolveAnchorSeq(snap && snap.chat, anchorKey);
402
+ const idx = seq != null ? findSeqIndex(items, seq) : -1;
403
+ if (prefs.scroll === "preserve" && idx >= 0) {
404
+ scrollToKey("fm-" + idx, false);
405
+ setActiveKey("fm-" + idx);
406
+ } else if (bodyEl) {
407
+ bodyEl.scrollTo({
408
+ top: bodyEl.scrollHeight,
409
+ behavior: "auto"
410
+ });
411
+ setActiveKey(navKeys.length ? navKeys[navKeys.length - 1] : null);
412
+ }
413
+ }, []);
414
+ const loadImage = (0, react.useCallback)((attachment) => {
415
+ if (!conversation || !currentId) return Promise.reject(/* @__PURE__ */ new Error("dsh-focus-overlay: conversation service unavailable"));
416
+ return conversation.resolveImage(currentId, attachment);
417
+ }, [conversation, currentId]);
418
+ const fileMentionsFor = (0, react.useCallback)((node) => {
419
+ if (!chatFileMentions) return void 0;
420
+ try {
421
+ return chatFileMentions.forClosing({
422
+ turn: {
423
+ turn: node.turn,
424
+ step: node.step
425
+ },
426
+ seq: node.seq,
427
+ openFile: (path) => {
428
+ if (workspaces) workspaces.openPath(path).catch(() => {});
429
+ }
430
+ });
431
+ } catch {
432
+ return;
433
+ }
434
+ }, [chatFileMentions, workspaces]);
435
+ let title = "Focus Mode";
436
+ if (currentId && listState && listState.byId && listState.byId[currentId]) {
437
+ const row = listState.byId[currentId];
438
+ if (row && row.displayTitle) title = row.displayTitle;
439
+ }
440
+ let body;
441
+ if (!currentId) body = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
442
+ className: "fm-empty",
443
+ children: t("empty")
444
+ });
445
+ else if (snap === null) body = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
446
+ className: "fm-empty",
447
+ children: t("loading")
448
+ });
449
+ else {
450
+ const partial = snap.partial;
451
+ const partialBlocks = partial ? partial.blocks : [];
452
+ const running = !!snap.running;
453
+ const kids = [];
454
+ for (let i = 0; i < items.length; i++) {
455
+ const it = items[i];
456
+ const key = "fm-" + i;
457
+ if (it.kind === "user") kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
458
+ className: "fm-msg fm-user-msg",
459
+ ref: (el) => {
460
+ if (el) anchors[key] = el;
461
+ else delete anchors[key];
462
+ },
463
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
464
+ className: "fm-user",
465
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MessageText, { text: it.text })
466
+ })
467
+ }, key));
468
+ else if (it.kind === "steering") kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
469
+ className: "fm-msg fm-user-msg",
470
+ ref: (el) => {
471
+ if (el) anchors[key] = el;
472
+ else delete anchors[key];
473
+ },
474
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
475
+ className: "fm-user fm-steering",
476
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MessageText, { text: it.text })
477
+ })
478
+ }, key));
479
+ else if (it.kind === "assistant") kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AssistantItem, {
480
+ blocks: it.blocks,
481
+ loadImage,
482
+ fileMentions: fileMentionsFor(it)
483
+ }, key));
484
+ else if (it.kind === "hidden") kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
485
+ className: "fm-hidden",
486
+ children: [
487
+ "· ",
488
+ it.text,
489
+ " ·"
490
+ ]
491
+ }, key));
492
+ else if (it.kind === "error") kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
493
+ className: "fm-msg fm-error",
494
+ children: it.text
495
+ }, key));
496
+ }
497
+ if (partialBlocks.length) kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AssistantItem, {
498
+ blocks: partialBlocks,
499
+ loadImage,
500
+ fileMentions: void 0
501
+ }, "fm-partial"));
502
+ if (running) kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
503
+ className: "fm-running",
504
+ children: t("running")
505
+ }, "fm-running"));
506
+ body = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
507
+ className: "fm-inner",
508
+ style: { maxWidth: prefs.width },
509
+ children: kids
510
+ });
511
+ }
512
+ const nav = prefs.navbar && navKeys.length >= 2 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
513
+ className: "fm-nav",
514
+ children: navKeys.map((k) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
515
+ type: "button",
516
+ "aria-label": navPreviews[k],
517
+ className: "fm-nav-dot" + (activeKey === k ? " fm-nav-dot-active" : ""),
518
+ onClick: () => scrollToKey(k, true),
519
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
520
+ className: "fm-nav-tip",
521
+ children: navPreviews[k]
522
+ })
523
+ }, k))
524
+ }) : null;
525
+ const toBottom = !atBottom ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
526
+ className: "fm-tobottom-wrap",
527
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
528
+ variant: "outline",
529
+ size: "sm",
530
+ className: "fm-tobottom",
531
+ title: t("backToLatest"),
532
+ onClick: scrollToBottom,
533
+ children: "↓"
534
+ })
535
+ }) : null;
536
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
537
+ className: "fm-overlay",
538
+ tabIndex: -1,
539
+ autoFocus: true,
540
+ onKeyDown: (e) => {
541
+ if (e && e.key === "Escape") focusStore.set(false);
542
+ },
543
+ children: [
544
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
545
+ className: "fm-topbar",
546
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
547
+ className: "fm-title",
548
+ children: title
549
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
550
+ variant: "outline",
551
+ size: "sm",
552
+ onClick: () => focusStore.set(false),
553
+ children: t("exit")
554
+ })]
555
+ }),
556
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
557
+ className: "fm-body",
558
+ ref: (el) => {
559
+ bodyEl = el;
560
+ },
561
+ onScroll: handleScroll,
562
+ children: body
563
+ }),
564
+ nav,
565
+ toBottom
566
+ ]
567
+ });
568
+ }
569
+ function FocusOverlay(props) {
570
+ if (!useFocus()) return null;
571
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FocusContent, { ...props });
572
+ }
573
+ function FocusToggle({ t }) {
574
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
575
+ variant: "ghost",
576
+ size: "sm",
577
+ title: t("toggle.title"),
578
+ onClick: () => {
579
+ focusStore.captureChatAnchor();
580
+ focusStore.set(true);
581
+ },
582
+ children: t("toggle")
583
+ });
584
+ }
585
+ function FocusSettings({ t }) {
586
+ const prefs = usePrefs();
587
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
588
+ className: "fm-settings",
589
+ children: [
590
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
591
+ className: "fm-setting-row",
592
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
593
+ type: "checkbox",
594
+ checked: prefs.navbar,
595
+ onChange: (e) => prefsStore.update({ navbar: e.target.checked })
596
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.navbar") })]
597
+ }),
598
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
599
+ className: "fm-setting-row",
600
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
601
+ type: "checkbox",
602
+ checked: prefs.scroll === "preserve",
603
+ onChange: (e) => prefsStore.update({ scroll: e.target.checked ? "preserve" : "bottom" })
604
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.scrollPreserve") })]
605
+ }),
606
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
607
+ className: "fm-setting-row",
608
+ children: [
609
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.width") }),
610
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
611
+ type: "range",
612
+ min: 480,
613
+ max: 1200,
614
+ step: 40,
615
+ value: prefs.width,
616
+ onChange: (e) => prefsStore.update({ width: Number(e.target.value) })
617
+ }),
618
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [prefs.width, "px"] })
619
+ ]
620
+ })
621
+ ]
622
+ });
623
+ }
624
+ //#endregion
625
+ //#region src/client/styles.ts
626
+ /** Package-owned stylesheet text (injected by the client apply, cleaned up on unload). */
627
+ const FOCUS_CSS = `
628
+ .fm-overlay{position:fixed;inset:0;z-index:2147483000;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);display:flex;flex-direction:column;pointer-events:auto;font-family:var(--dsw-font-family,sans-serif);outline:none}
629
+ .fm-topbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.12));flex:0 0 auto}
630
+ .fm-title{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
631
+ .fm-body{flex:1 1 auto;overflow-y:auto;padding:28px 28px 64px}
632
+ .fm-inner{max-width:760px;margin:0 auto}
633
+ .fm-msg{margin:0 0 24px}
634
+ .fm-user-msg{margin-bottom:44px}
635
+ .fm-user{background:var(--dsw-specific-bubble,#eaf2ff);color:var(--dsw-alias-label-primary,#1a1a1a);border-radius:14px;padding:10px 14px;word-break:break-word;max-width:85%;margin-left:auto}
636
+ .fm-steering{opacity:.85}
637
+ .fm-assistant{word-break:break-word}
638
+ .fm-image{max-width:100%;border-radius:8px;margin:8px 0}
639
+ .fm-hidden{text-align:center;color:var(--dsw-alias-label-secondary,#888);font-size:12px;line-height:1.6;margin:6px 0 18px;user-select:none}
640
+ .fm-error{color:var(--dsw-alias-state-error-primary,#d23);font-size:13px;white-space:pre-wrap}
641
+ .fm-running{color:var(--dsw-alias-label-secondary,#888);font-size:13px;margin:6px 0 16px}
642
+ .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:flex;flex-direction:column;align-items:center;gap:5px;z-index:2;pointer-events:auto}
644
+ .fm-nav-dot{position:relative;cursor:pointer;background:var(--dsw-alias-label-secondary,#999);border:0;border-radius:999px;width:8px;height:8px;padding:0;transition:background .15s,height .15s}
645
+ .fm-nav-dot:hover{background:var(--dsw-alias-brand-primary,#4c6ef5)}
646
+ .fm-nav-dot-active{background:var(--dsw-alias-brand-primary,#4c6ef5);height:22px;width:8px}
647
+ .fm-nav-tip{position:absolute;right:18px;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
+ .fm-nav-dot:hover .fm-nav-tip{display:block}
649
+ .fm-tobottom-wrap{position:absolute;left:50%;transform:translateX(-50%);bottom:18px;z-index:2;pointer-events:none}
650
+ .fm-tobottom{pointer-events:auto;border-radius:100px;min-width:34px;height:34px;padding:0 10px}
651
+ .fm-settings{display:flex;flex-direction:column;gap:16px;max-width:560px;padding:8px 0}
652
+ .fm-setting-row{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer;user-select:none}
653
+ .fm-setting-row input[type="checkbox"]{width:16px;height:16px;cursor:pointer}
654
+ .fm-setting-row input[type="range"]{flex:1;max-width:240px;cursor:pointer}
655
+ `;
656
+ //#endregion
657
+ //#region src/client/locales.ts
658
+ /** i18n dictionaries for the `focus` namespace. */
659
+ const zh = {
660
+ "toggle": "专注",
661
+ "toggle.title": "进入专注模式",
662
+ "exit": "退出专注 (Esc)",
663
+ "backToLatest": "回到最新",
664
+ "settings.label": "专注模式",
665
+ "settings.navbar": "显示右侧导航条",
666
+ "settings.scrollPreserve": "打开时保留聊天位置",
667
+ "settings.width": "文字区宽度",
668
+ "empty": "暂无打开的会话",
669
+ "loading": "正在读取会话…",
670
+ "running": "· 正在工作 …",
671
+ "sum.context": "载入了 {n} 项上下文",
672
+ "sum.commands": "运行了 {n} 个命令",
673
+ "sum.edits": "编辑了 {n} 个文件",
674
+ "sum.searches": "搜索了 {n} 个正则",
675
+ "sum.files": "读取了 {n} 个文件",
676
+ "sum.dirs": "列出了 {n} 个目录",
677
+ "sum.subagents": "fork 了 {n} 个子代理",
678
+ "sum.todos": "更新了待办",
679
+ "sum.goals": "更新了目标",
680
+ "sum.workflows": "运行了 {n} 个工作流",
681
+ "sum.skills": "载入了 {n} 个技能",
682
+ "sum.questions": "问了 {n} 个问题",
683
+ "sum.plans": "计划了 {n} 次",
684
+ "sum.jobs": "后台任务 {n} 个",
685
+ "sum.others": "调用了 {n} 个工具"
686
+ };
687
+ const en = {
688
+ "toggle": "Focus",
689
+ "toggle.title": "Enter focus mode",
690
+ "exit": "Exit focus (Esc)",
691
+ "backToLatest": "Back to latest",
692
+ "settings.label": "Focus Mode",
693
+ "settings.navbar": "Show right-side navbar",
694
+ "settings.scrollPreserve": "Preserve chat position on open",
695
+ "settings.width": "Text width",
696
+ "empty": "No open session",
697
+ "loading": "Reading session…",
698
+ "running": "· Working …",
699
+ "sum.context": "loaded {n} context items",
700
+ "sum.commands": "ran {n} commands",
701
+ "sum.edits": "edited {n} files",
702
+ "sum.searches": "ran {n} searches",
703
+ "sum.files": "read {n} files",
704
+ "sum.dirs": "listed {n} directories",
705
+ "sum.subagents": "forked {n} subagents",
706
+ "sum.todos": "updated todos",
707
+ "sum.goals": "updated goals",
708
+ "sum.workflows": "ran {n} workflows",
709
+ "sum.skills": "loaded {n} skills",
710
+ "sum.questions": "asked {n} questions",
711
+ "sum.plans": "planned {n} times",
712
+ "sum.jobs": "{n} background jobs",
713
+ "sum.others": "called {n} tools"
714
+ };
715
+ //#endregion
716
+ //#region src/client/index.ts
717
+ /**
718
+ * Focus Mode, browser half: a full-screen reading overlay in `shell.overlay`
719
+ * (additive, never replaces shipped UI), a "专注" button in the session header
720
+ * action row, and a settings section. Rendering reuses the official
721
+ * `@deepseek-ai/dsh-client-ui-primitives` Markdown/MessageText/Tooltip/Button
722
+ * components and the conversation service's image resolver.
723
+ */
724
+ const NS = "focus";
725
+ //#endregion
726
+ module.exports = {
727
+ name: "dsh-focus-overlay-client",
728
+ inject: [
729
+ "slots",
730
+ "sessions",
731
+ "locale",
732
+ "workspaces"
733
+ ],
734
+ apply(ctx) {
735
+ const style = document.createElement("style");
736
+ style.setAttribute("data-plugin", "dsh-focus-overlay");
737
+ style.textContent = FOCUS_CSS;
738
+ ctx.effect(() => {
739
+ document.head.appendChild(style);
740
+ return () => {
741
+ style.remove();
742
+ };
743
+ });
744
+ ctx.effect(() => ctx.locale.register(NS, {
745
+ zh,
746
+ en
747
+ }));
748
+ const t = ctx.locale.bind(NS);
749
+ const sessions = ctx.sessions;
750
+ const workspaces = ctx.workspaces;
751
+ const conversation = ctx.get("conversation");
752
+ const chatFileMentions = ctx.get("chatFileMentions");
753
+ ctx.slots.inject("shell.overlay", () => ctx.slots.register({
754
+ name: "shell.overlay",
755
+ id: "focus-mode-overlay",
756
+ order: 1e3
757
+ }, (props) => (0, react.createElement)(FocusOverlay, {
758
+ ...props,
759
+ sessions,
760
+ workspaces,
761
+ conversation,
762
+ chatFileMentions,
763
+ t
764
+ })));
765
+ ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
766
+ name: "conversation.session.header.actions",
767
+ id: "focus-mode-toggle",
768
+ order: 1e3,
769
+ label: () => t("toggle")
770
+ }, () => (0, react.createElement)(FocusToggle, { t })));
771
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
772
+ name: "settings.section",
773
+ id: "focus-mode-settings",
774
+ order: 100,
775
+ label: () => t("settings.label")
776
+ }, () => (0, react.createElement)(FocusSettings, { t })));
777
+ }
778
+ };
779
+ return module.exports;
780
+ }
781
+ });
package/lib/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ //#region src/index.mjs
2
+ function apply() {}
3
+ //#endregion
4
+ export { apply as default };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "dsh-focus-overlay",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "lib/index.mjs",
7
+ "files": [
8
+ "lib",
9
+ "cordis.patch.yml"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "default": "./lib/index.mjs"
14
+ },
15
+ "./client": "./lib/client.js",
16
+ "./cordis.patch.yml": "./cordis.patch.yml",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "dsh": {
20
+ "bundle": {
21
+ "patch": "./cordis.patch.yml"
22
+ },
23
+ "client": {
24
+ "inject": [
25
+ "@deepseek-ai/dsh-client-runtime",
26
+ "@deepseek-ai/dsh-client-ui-primitives"
27
+ ],
28
+ "platform": "web"
29
+ }
30
+ },
31
+ "peerDependencies": {
32
+ "@deepseek-ai/cordis": "^4.0.1"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.19.0",
36
+ "dsh": ">=0.1.0-rc.5"
37
+ },
38
+ "scripts": {
39
+ "build": "tsdown --config ./tsdown.config.ts",
40
+ "prepare": "npm run build"
41
+ },
42
+ "keywords": [
43
+ "dsh",
44
+ "deepseek-harness",
45
+ "plugin",
46
+ "focus-mode",
47
+ "bundle"
48
+ ],
49
+ "devDependencies": {
50
+ "tsdown": "0.22.2"
51
+ },
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "https://github.com/boogoo619/dsh-focus-overlay.git"
55
+ },
56
+ "license": "MIT"
57
+ }