dsh-newbe-response-window 0.2.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 heiheiha798
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,93 @@
1
+ # dsh-newbe-response-window
2
+
3
+ DeepSeek Harness (DSH) Web 插件:把一轮里的 think(推理)和工具调用放进**有限高度的可滚动窗口**(默认 10 行),以**阶段性文字回复为界分段**,每个分段一个 **slide**;文字回复保持原生完整显示、不做限高。中间过程**始终可见**(只是不撑爆页面)——Grok Build 风格。
4
+
5
+ > 已验证 DSH 版本:**0.1.2-alpha.5**(2026-09)。
6
+ > 与「全部折叠成 summary」类插件(如 `dsh-tool-summary`)不同:本插件**不隐藏**任何内容。think 与工具调用一条条列在 slide 里,每段都能展开翻到底;文字回复完全原生展示。
7
+ > 只包 bash + think:阶段性/最终文字 response **不**加限高、**不**加「展开全部/收起」按钮。
8
+
9
+ ## 效果
10
+
11
+ 一个 prompt 之后有 100 次 tool call + 长回复时:
12
+
13
+ - **think(推理)和工具调用** → 合并为 slide(think 与 tool call 都是 implementation,不做区分):
14
+ - **以「阶段性文字回复」为界分段**:模型中途直接返回给用户的每条文字 response 是天然分隔点;两个分隔点之间的一段 think+bash 是一个 slide。不会两个 input prompt 之间挤成一整个 slide
15
+ - 头部:`10 个工具调用 · 5 Think`(无 emoji、无冗余序号)+ 进行中 / 失败徽标,可点击收起/展开
16
+ - 主体:`max-height: N 行`(默认 10)的**内部滚动区**
17
+ - **think 与工具调用严格按真实输出顺序交错排列**(不做「think 全在上 / bash 全在下」的强行分区)
18
+ - 每段 think 一行(Think + 单行摘要 + 展开箭头),默认折叠,点击展开完整推理(内部限高滚动);展开文字与工具输出同字号(同一 mono 字体)
19
+ - 每条工具调用一行(状态点 + 工具名 + 单行摘要),点击展开参数/输出(输出再限高一档,内部滚动)
20
+ - 执行中自动跟随底部
21
+ - **原生 Think 行从消息流中隐藏**(该段已并入 slide,避免重复显示),与 slide 内展示共用同一份内容
22
+ - **阶段性文字回复与最终回复** → 完全原生展示,保持全文可见,**不套 slide 窗口**、不加任何按钮
23
+ - 用户消息始终是「整轮的分隔点」,保持原位
24
+
25
+ > 效果预览见 GitHub 仓库页面或自行安装体验(仓库不再内置运行截图,避免暴露会话内容)。
26
+
27
+ ## 安装
28
+
29
+ ```bash
30
+ dsh plugin --profile web add github:qiqiangvae/dsh-newbe-plugins#path:packages/dsh-newbe-response-window
31
+ ```
32
+
33
+ 或本地 link 方式(开发调试):
34
+
35
+ ```bash
36
+ git clone https://github.com/qiqiangvae/dsh-newbe-plugins.git
37
+ cd dsh-newbe-plugins
38
+ dsh plugin --profile web add "link:$(pwd)/packages/dsh-newbe-response-window"
39
+ ```
40
+
41
+ 装完重启 `dsh web`(或等 profile HMR)生效。
42
+
43
+ 卸载:`dsh plugin --profile web remove dsh-newbe-response-window`
44
+
45
+ > 迁移提示:本插件已从独立仓库迁入 newbe monorepo,老安装地址(仓库根 `github:qiqiangvae/dsh-newbe-plugins`)已失效。插件名仍是 `dsh-newbe-response-window`——先 `dsh plugin --profile web remove dsh-newbe-response-window`,再用上方新地址重装即可。
46
+
47
+ ## 配置
48
+
49
+ `cordis.patch.yml` 里插入了默认配置,可改:
50
+
51
+ | 键 | 默认 | 说明 |
52
+ | --- | --- | --- |
53
+ | `lines` | `10` | 窗口高度(行)。`0` = 不限高(等价于关闭窗口) |
54
+ | `collapsed` | `false` | 每轮 slide 是否默认收起成一行 bar。默认 `false`:始终展开、内容可见 |
55
+ | `showReadOnly` | `true` | 是否在 slide 里列出 read/grep/web_search 等只读调用(默认全列出,不藏) |
56
+ | `minCollapseRows` | `3` | 仅 `collapsed: true` 时生效:少于该数量的轮次不收起 |
57
+
58
+ ### 设置页(Settings → General)
59
+
60
+ 插件在 Web UI 的 **Settings → General** 里注册了一项 **「响应窗口大小(行数)」**:
61
+
62
+ - `−` / 数值输入 / `+`:调整 `lines`(0–200,`0` = 不限高,默认 10)
63
+ - **即时生效**:改动后已渲染的 slide 高度立刻变化(经宿主 settings namespace `dsh-newbe-response-window` 持久化)
64
+
65
+ ## 实现说明(为什么安全)
66
+
67
+ - 工具调用 slide 通过 `conversation.chat.node`(`tool-call` key,`priority: -100`)的 **slot shadow** 在 React 层实现:每轮第一个 tool-call 节点渲染整个 slide,同轮其余 tool-call 节点渲染空,任何渲染异常由 slot 运行时的 entry boundary **自动 abdicate** 回内置渲染。
68
+ - **绝不移走 React 拥有的 `[data-chat-anchor-key]` 行节点**。实测:把行移进自定义容器后,一旦 DSH 后续移除该行(会话切换/编辑/压缩),React 会调用 `parent.removeChild(row)` 抛 `NotFoundError`,整个会话树被卸载——因此本插件只用「slot shadow + 类/CSS」两种方式,对 React 行结构零改动。
69
+ - 原生 Think 行隐藏与 slide 内的 think 同步:只对「该段内有 slide」的原生 `data-variant="think"` 行加 `display:none`(DOM 类/CSS,无重挂、无删除),其余(无工具调用的纯 Think 段)保持原生显示。
70
+ - 插件只读会话快照,不写快照、不调宿主 API。0.1.2-alpha 起快照从 slot 标准 prop **`useChat`** 读取(`{ locations.getTurn(turn), nodes.get(key) }` 形状),不再走 `useSession` 的 `chat` 字段;「幽灵行」判定通过 DOM 行上的 `data-chat-flow-key` 与该快照对号,不再嗅探 markdown CSS 类名(新版 CSS Module 类名已不含 `_markdown`)。
71
+ - 浏览器半依赖 `@deepseek-ai/dsh-client-store`(`createSnapshotStore`,shell 内置 seed module),替代旧版的 `@deepseek-ai/dsh-client-runtime`(该包在新 alpha 已移除)。旧版 `dsh.plugin.json` 清单在新 alpha 不再被读取,已删除。
72
+
73
+ ## 开发 / 测试
74
+
75
+ 纯 JS,无构建步骤:
76
+
77
+ ```bash
78
+ npm run check # node --check lib/index.js && node --check lib/client.js
79
+ ```
80
+
81
+ E2E(需要已启动的 `dsh web` 与 python playwright):
82
+
83
+ ```bash
84
+ dsh --profile web --no-open --port 3639 &
85
+ python3 test/e2e.py --url http://127.0.0.1:3639 --session "架构重构不顺原因分析"
86
+ ```
87
+
88
+ - 宿主半:`lib/index.js`(注册 settings namespace,依赖 `@deepseek-ai/schemastery`)
89
+ - 浏览器半:`lib/client.js`(`window.__ModuleLoader__.load`,require `react` 与 shell seed 的 `@deepseek-ai/dsh-client-store`)
90
+
91
+ ## License
92
+
93
+ MIT
@@ -0,0 +1,29 @@
1
+ # dsh-newbe-response-window bundle patch
2
+ #
3
+ # Verified against DSH 0.1.2-alpha.5. Installing this package into a profile
4
+ # (`dsh plugin --profile <profile> add …`) appends it to dsh.profile.bundles
5
+ # because package.json declares `dsh.bundle.patch`. The profile boot applies
6
+ # this insert list, which mounts the package as a Cordis plugin: the host half
7
+ # (exports ".") runs in the host process and — via the `dsh.client` declaration
8
+ # in package.json — the browser half (exports "./client", served at
9
+ # /plugins/dsh-newbe-response-window/client.js) runs in the web GUI.
10
+ #
11
+ # Row config (all optional):
12
+ # lines window height in lines (default 10). 0 disables the
13
+ # height cap (blocks render at natural height).
14
+ # collapsed start every turn slide collapsed to a one-line bar
15
+ # instead of always-expanded (default false: content stays
16
+ # visible, just bounded).
17
+ # showReadOnly list read / grep / glob / web_search etc. in the slide
18
+ # (default true: nothing is hidden by default).
19
+ # minCollapseRows only auto-collapse a finished slide when it has at least
20
+ # this many tool calls (default 3; only used with
21
+ # collapsed=true).
22
+ - insert:
23
+ - id: dsh-newbe-response-window
24
+ name: 'dsh-newbe-response-window'
25
+ config:
26
+ lines: 10
27
+ collapsed: false
28
+ showReadOnly: true
29
+ minCollapseRows: 3
package/lib/client.js ADDED
@@ -0,0 +1,826 @@
1
+ // dsh-newbe-response-window — browser half.
2
+ //
3
+ // Adapted for DSH 0.1.2-alpha.5 (the current client-module / slot-runtime
4
+ // generation). Breaking changes vs. the older alpha this plugin shipped for:
5
+ //
6
+ // - `@deepseek-ai/dsh-client-runtime` is gone. The browser shell now seeds
7
+ // `@deepseek-ai/dsh-client-store` (createSnapshotStore) as a static module,
8
+ // so the bundle requires that instead — no dsh.client.inject needed.
9
+ // - `conversation.chat.node` moved from dsh-client-ui-conversation to
10
+ // dsh-client-ui-chat; the keyed slot contract is the same (`key`,
11
+ // `priority`, lowest priority renders), but its owner props no longer carry
12
+ // `useSession`. Session facts arrive through the slot system's standard
13
+ // props — the per-conversation chat snapshot is the standard `useChat`
14
+ // selector hook (shape: `{ locations.getTurn(turn), nodes.get(key) }`).
15
+ // - Assistant markdown CSS-module classes changed shape (hash_root, no
16
+ // `_markdown` substring), so DOM class sniffing is gone: ghost-row and
17
+ // segment-boundary decisions now read the chat snapshot through the
18
+ // `data-chat-flow-key` attribute instead of markdown classes.
19
+ // - Theme tokens: border-weak/strong → border-l1/l2, brand →
20
+ // state-business-primary; `--dsw-font-mono` → `--ds-font-family-code`.
21
+ // Every token keeps a fallback, so missing tokens degrade gracefully.
22
+ // - `dsh.plugin.json` is no longer read by the harness (removed).
23
+ //
24
+ // What it does (Grok-build style response window):
25
+ // 1. PER-TURN TOOL SLIDE: every tool call of one turn (the response between
26
+ // two user prompts) is grouped into ONE slide. The slide is ALWAYS
27
+ // expanded by default and shows every call — state dot, tool name, one-line
28
+ // summary, click to expand parameters/output — inside a bounded-height
29
+ // scroll body (config `lines`, default 10). Nothing is hidden: it is a
30
+ // window, not a summary. While calls are still running the body
31
+ // auto-follows to the bottom.
32
+ // 2. ASSISTANT TEXT WINDOW: a settled assistant markdown block longer than
33
+ // the window is capped with internal scroll + a subtle gradient and an
34
+ // expand/collapse affordance. The native MarkdownText rendering is kept
35
+ // untouched (class + CSS only).
36
+ //
37
+ // GUI-context safety contract (informed by code audit + a live React crash
38
+ // experiment):
39
+ // - Moving React-owned chat rows into a wrapper container is NOT safe: when
40
+ // DSH later removes a moved row (session switch / edit / compaction), React
41
+ // calls parent.removeChild(row) which throws because the row is no longer a
42
+ // direct child, and the whole conversation tree unmounts. Therefore this
43
+ // plugin NEVER moves [data-chat-anchor-key] rows.
44
+ // - The tool slide is implemented at the React layer via a low-priority slot
45
+ // shadow of `conversation.chat.node` (key `tool-call`, priority -100): the
46
+ // first tool-call node of a turn renders the slide, every sibling tool-call
47
+ // node of the same turn renders null, and the slot runtime's entry boundary
48
+ // abdicates our renderer back to the built-in one on any crash.
49
+ // - The assistant text window is class/CSS only — no DOM reparenting, no
50
+ // removal of React-owned nodes.
51
+ // - The plugin never writes the session snapshot and never calls host APIs;
52
+ // it only reads the chat snapshot (useChat selectors) and styles DOM.
53
+
54
+ window.__ModuleLoader__.load({
55
+ id: 'dsh-newbe-response-window',
56
+ factory: (require) => {
57
+ var module = { exports: {} }
58
+ var exports = module.exports
59
+ var React = require('react')
60
+ var storeLib = require('@deepseek-ai/dsh-client-store')
61
+ var createSnapshotStore = storeLib.createSnapshotStore
62
+
63
+ // ---- config -----------------------------------------------------------
64
+ var DEFAULTS = {
65
+ lines: 10,
66
+ collapsed: false,
67
+ showReadOnly: true,
68
+ minCollapseRows: 3,
69
+ }
70
+ function clampInt(value, min, max, fallback) {
71
+ var n = Number(value)
72
+ if (!Number.isFinite(n)) return fallback
73
+ return Math.min(max, Math.max(min, Math.round(n)))
74
+ }
75
+ function readConfig(cfg) {
76
+ cfg = cfg || {}
77
+ return {
78
+ lines: clampInt(cfg.lines, 0, 200, DEFAULTS.lines),
79
+ collapsed: cfg.collapsed === true,
80
+ showReadOnly: cfg.showReadOnly !== false,
81
+ minCollapseRows: clampInt(cfg.minCollapseRows, 1, 50, DEFAULTS.minCollapseRows),
82
+ }
83
+ }
84
+
85
+ // ---- live settings state ----------------------------------------------
86
+ // `lines` is user-adjustable from Settings; the rest come from row config.
87
+ var linesStore = null
88
+ var settingsScope = null
89
+ var NS = 'dsh-newbe-response-window'
90
+ function clampLines(value) { return clampInt(value, 0, 200, DEFAULTS.lines) }
91
+
92
+ /** Minimal observable-value hook over a SnapshotStore (plain React). */
93
+ function useStoreValue(store) {
94
+ var pair = React.useState(function () { return store === null ? DEFAULTS.lines : store.getSnapshot() })
95
+ var value = pair[0]
96
+ var setValue = pair[1]
97
+ React.useEffect(function () {
98
+ if (store === null) return undefined
99
+ var unsub = store.subscribe(function () { setValue(store.getSnapshot()) })
100
+ setValue(store.getSnapshot())
101
+ return unsub
102
+ }, [store])
103
+ return value
104
+ }
105
+
106
+ /** Live window size in lines (follows the Settings store). */
107
+ function useLiveLines() {
108
+ return useStoreValue(linesStore)
109
+ }
110
+
111
+ // ---- shared style injection ------------------------------------------
112
+ var STYLE_ID = 'dsh-newbe-response-window-css'
113
+ function injectStyles() {
114
+ if (typeof document === 'undefined') return
115
+ if (document.getElementById(STYLE_ID)) return
116
+ var el = document.createElement('style')
117
+ el.id = STYLE_ID
118
+ el.textContent = [
119
+ /* per-turn tool slide */
120
+ '.drw-slide {',
121
+ ' position: relative;',
122
+ ' display: flex; flex-direction: column;',
123
+ ' margin: 6px 0;',
124
+ ' border: 1px solid var(--dsw-alias-border-l2, var(--dsw-alias-border-strong, rgba(128,128,128,0.35)));',
125
+ ' border-radius: 10px;',
126
+ ' background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,0.06));',
127
+ ' overflow: hidden;',
128
+ '}',
129
+ '.drw-slide[data-running="1"] { border-color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand, #3dbbf5)); }',
130
+ /* Tool-call rows that our per-turn slide renders null for (sibling
131
+ nodes of a grouped turn) are empty in the DOM: hide the leftover
132
+ ~0-height stubs. Only truly empty rows match, so a native fallback
133
+ (renderer abdication) staying non-empty is never hidden. The shell
134
+ also hides empty flow items natively — this rule is belt and braces. */
135
+ '[data-chat-flow-kind="tool-call"]:empty { display: none; }',
136
+ '[data-chat-flow-kind][data-drw-stub="1"] { display: none !important; }',
137
+ '.drw-head {',
138
+ ' display: flex; align-items: center; gap: 8px;',
139
+ ' padding: 6px 10px;',
140
+ ' cursor: pointer; user-select: none;',
141
+ ' color: var(--dsw-alias-label-primary, #eee);',
142
+ ' font-size: 12.5px; line-height: 1.4;',
143
+ ' background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.10));',
144
+ '}',
145
+ '.drw-head:hover { background: var(--dsw-alias-interactive-bg-hover-solid, rgba(128,128,128,0.16)); }',
146
+ '.drw-head-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
147
+ '.drw-head-badge {',
148
+ ' font-size: 11px; line-height: 1; padding: 2px 7px; border-radius: 999px;',
149
+ ' background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.2));',
150
+ ' color: var(--dsw-alias-label-secondary, #bbb);',
151
+ ' white-space: nowrap;',
152
+ '}',
153
+ '.drw-head-badge[data-state="running"] { color: var(--dsw-alias-state-business-primary, #3dbbf5); }',
154
+ '.drw-head-badge[data-state="error"] { color: var(--dsw-alias-state-error-primary, #ee5858); }',
155
+ '.drw-head-toggle { margin-left: auto; color: var(--dsw-alias-label-secondary, #bbb); font-size: 11.5px; }',
156
+ '.drw-body { overflow-y: auto; overscroll-behavior: contain; }',
157
+ '.drw-body.drw-collapsed { display: none; }',
158
+ '.drw-call { border-top: 1px solid var(--dsw-alias-border-l1, var(--dsw-alias-border-weak, rgba(128,128,128,0.16))); }',
159
+ '.drw-call:first-child { border-top: none; }',
160
+ '.drw-row {',
161
+ ' display: flex; align-items: center; gap: 7px;',
162
+ ' padding: 5px 10px;',
163
+ ' cursor: pointer; user-select: none;',
164
+ ' color: var(--dsw-alias-label-primary, #eee);',
165
+ ' font-size: 12.5px; line-height: 1.35;',
166
+ '}',
167
+ '.drw-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.10)); }',
168
+ '.drw-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }',
169
+ '.drw-dot[data-state="ok"] { background: var(--dsw-alias-state-success-primary, #34d59a); }',
170
+ '.drw-dot[data-state="running"] { background: var(--dsw-alias-state-business-primary, #3dbbf5); animation: drwPulse 1s ease-in-out infinite; }',
171
+ '.drw-dot[data-state="error"] { background: var(--dsw-alias-state-error-primary, #ee5858); }',
172
+ '.drw-dot[data-state="stopped"] { background: var(--dsw-alias-state-warn-primary, #c9a227); }',
173
+ '@keyframes drwPulse { 50% { opacity: 0.35; } }',
174
+ '.drw-row-name { font-family: var(--ds-font-family-code, var(--dsw-font-mono, ui-monospace, monospace)); font-size: 12px; white-space: nowrap; }',
175
+ '.drw-row-summary { color: var(--dsw-alias-label-secondary, #bbb); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }',
176
+ '.drw-inspect { border: none; background: transparent; color: var(--dsw-alias-label-secondary, #bbb); cursor: pointer; padding: 0 2px; font-size: 12px; }',
177
+ '.drw-inspect:hover { color: var(--dsw-alias-label-primary, #eee); }',
178
+ '.drw-chevron { color: var(--dsw-alias-label-secondary, #bbb); font-size: 10px; transition: transform 0.12s ease; }',
179
+ '.drw-chevron[data-open="1"] { transform: rotate(90deg); }',
180
+ '.drw-row-body { padding: 2px 10px 8px 28px; }',
181
+ '.drw-row-label { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--dsw-alias-label-tertiary, #888); margin: 6px 0 3px; }',
182
+ '.drw-row-pre {',
183
+ ' margin: 0; padding: 6px 8px;',
184
+ ' border-radius: 6px;',
185
+ ' background: var(--dsw-alias-markdown-code-block, rgba(0,0,0,0.25));',
186
+ ' color: var(--dsw-alias-label-primary, #eee);',
187
+ ' font: var(--dsw-font-markdown-code-block-small, 12px/1.5 ui-monospace, monospace);',
188
+ ' white-space: pre-wrap; word-break: break-word;',
189
+ ' max-height: 10em; overflow-y: auto;',
190
+ '}',
191
+ /* Think (reasoning) rows inside the slide — one line each, like tool rows */
192
+ '.drw-think { border-top: 1px solid var(--dsw-alias-border-l2, var(--dsw-alias-border-strong, rgba(128,128,128,0.16))); flex: none; }',
193
+ '.drw-think-head { display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 0 10px; cursor: pointer; user-select: none; border-radius: 8px; }',
194
+ '.drw-think-head:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.08)); }',
195
+ '.drw-think-name { color: var(--dsw-alias-label-secondary, #bbb); font-size: 11px; font-weight: 600; letter-spacing: 0.04em; flex: none; }',
196
+ '.drw-think-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary, #999); font-size: 12.5px; flex: 1 1 auto; }',
197
+ '.drw-think-toggle { color: var(--dsw-alias-label-tertiary, #888); font-size: 10px; flex: none; }',
198
+ '.drw-think-body { margin: 0; padding: 6px 8px; color: var(--dsw-alias-label-primary, #eee); font: var(--dsw-font-markdown-code-block-small, 12px/1.5 ui-monospace, monospace); white-space: pre-wrap; word-break: break-word; max-height: 240px; overflow-y: auto; }',
199
+ /* Hide the native Think row once it's inside a slide (DOM + CSS only) */
200
+ '[data-variant="think"][data-drw-hidethink="1"] { display: none !important; }',
201
+ /* Settings row: response window size */
202
+ '.drw-set-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; padding: 16px 0 18px; }',
203
+ '.drw-set-text { min-width: 0; }',
204
+ '.drw-set-title { color: var(--dsw-alias-label-primary, #eee); font-size: 14px; font-weight: 400; line-height: 22px; }',
205
+ '.drw-set-desc { color: var(--dsw-alias-label-tertiary, #9296a0); font-size: 12px; line-height: 18px; margin-top: 3px; }',
206
+ '.drw-set-control { display: flex; align-items: center; gap: 6px; flex: none; }',
207
+ '.drw-set-btn {',
208
+ ' border: 1px solid var(--dsw-alias-border-l2, var(--dsw-alias-border-strong, rgba(128,128,128,0.35)));',
209
+ ' background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,0.08));',
210
+ ' color: var(--dsw-alias-label-primary, #eee);',
211
+ ' border-radius: 8px; height: 28px; min-width: 28px; padding: 0 8px; cursor: pointer;',
212
+ ' font-size: 14px; line-height: 1;',
213
+ '}',
214
+ '.drw-set-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16)); }',
215
+ '.drw-set-btn:disabled { opacity: 0.4; cursor: default; }',
216
+ '.drw-set-input {',
217
+ ' width: 56px; height: 28px; text-align: center;',
218
+ ' border: 1px solid var(--dsw-alias-border-l2, var(--dsw-alias-border-strong, rgba(128,128,128,0.35)));',
219
+ ' border-radius: 8px; background: var(--dsw-alias-bg-layer-1, #1c1c20);',
220
+ ' color: var(--dsw-alias-label-primary, #eee); font-size: 13px;',
221
+ '}',
222
+ ].join('\n')
223
+ ;(document.head || document.documentElement).appendChild(el)
224
+ }
225
+
226
+ // ---- tool data helpers -------------------------------------------------
227
+ var READONLY_TOOLS = new Set([
228
+ 'read', 'grep', 'glob', 'web_search', 'web_fetch', 'search', 'ls', 'find', 'list',
229
+ ])
230
+ function turnNumber(node) {
231
+ var location = node && node.location
232
+ if (!location) return undefined
233
+ if (location.kind === 'turn' || location.kind === 'step') {
234
+ var t = location.turn
235
+ return t && typeof t.turn === 'number' ? t.turn : undefined
236
+ }
237
+ return undefined
238
+ }
239
+ function callName(block) {
240
+ return block && ('kind' in block ? (block.call && block.call.name) || '' : block.name) || ''
241
+ }
242
+ function isRunning(block) {
243
+ return !(block && 'kind' in block)
244
+ }
245
+ function resultText(block) {
246
+ if (!block || !('kind' in block)) return ''
247
+ var parts = []
248
+ var content = block.content || []
249
+ for (var i = 0; i < content.length; i++) {
250
+ var c = content[i]
251
+ if (c && c.type === 'text' && typeof c.text === 'string') parts.push(c.text)
252
+ }
253
+ return parts.join('\n')
254
+ }
255
+ function callSummary(block) {
256
+ var name = callName(block)
257
+ var raw = (block && ('kind' in block ? (block.call && block.call.argsRaw) || '' : block.argsRaw)) || ''
258
+ if (raw === '') return name
259
+ try {
260
+ var parsed = JSON.parse(raw)
261
+ if (typeof parsed !== 'object' || parsed === null) return name + ' · ' + raw
262
+ for (var i = 0; i < KEYS.length; i++) {
263
+ var value = parsed[KEYS[i]]
264
+ if (typeof value === 'string' && value !== '') return value
265
+ }
266
+ return name + ' · ' + raw.slice(0, 80)
267
+ } catch (e) {
268
+ return name + ' · ' + raw.slice(0, 80)
269
+ }
270
+ }
271
+ var KEYS = ['file_path', 'path', 'command', 'url', 'pattern', 'query']
272
+ function computeStats(blocks) {
273
+ var counts = Object.create(null)
274
+ var total = 0, running = 0, errors = 0, readOnly = 0
275
+ for (var i = 0; i < blocks.length; i++) {
276
+ var block = blocks[i]
277
+ if (!block) continue
278
+ var name = callName(block)
279
+ total += 1
280
+ counts[name] = (counts[name] || 0) + 1
281
+ if (isRunning(block)) running += 1
282
+ else if (block.isError) errors += 1
283
+ if (READONLY_TOOLS.has(name)) readOnly += 1
284
+ }
285
+ return { total: total, running: running, errors: errors, readOnly: readOnly, counts: counts }
286
+ }
287
+
288
+ // ---- one compact tool row ----------------------------------------------
289
+ function SimpleToolRow(props) {
290
+ var block = props.block
291
+ var openFile = props.openFile
292
+ var inspectCall = props.inspectCall
293
+ var state = React.useState(false)
294
+ var open = state[0]
295
+ var setOpen = state[1]
296
+ var running = isRunning(block)
297
+ var name = callName(block)
298
+ var raw = (block && ('kind' in block ? (block.call && block.call.argsRaw) || '' : block.argsRaw)) || ''
299
+ var output = resultText(block)
300
+ var failed = !running && !!(block && block.isError)
301
+ var stopped = !running && !!(block && block.error) && !failed
302
+ var stateName = running ? 'running' : failed ? 'error' : stopped ? 'stopped' : 'ok'
303
+ return React.createElement('div', { className: 'drw-call', 'data-state': stateName },
304
+ React.createElement('div', {
305
+ className: 'drw-row', role: 'button', tabIndex: 0,
306
+ 'aria-expanded': open || undefined,
307
+ onClick: function () { setOpen(!open) },
308
+ onKeyDown: function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(!open) } },
309
+ },
310
+ React.createElement('span', { className: 'drw-dot', 'data-state': stateName, 'aria-hidden': true }),
311
+ React.createElement('span', { className: 'drw-row-name', title: name }, name || (block && block.callId) || 'tool'),
312
+ React.createElement('span', { className: 'drw-row-summary', title: output || raw }, callSummary(block)),
313
+ typeof inspectCall === 'function'
314
+ ? React.createElement('button', {
315
+ type: 'button', className: 'drw-inspect', title: '在轨迹中查看', 'aria-label': '在轨迹中查看 ' + name,
316
+ onClick: function (e) { e.stopPropagation(); inspectCall(block.callId) },
317
+ }, '\u2934')
318
+ : null,
319
+ React.createElement('span', { className: 'drw-chevron', 'data-open': open ? '1' : undefined, 'aria-hidden': true }, '\u25B6'),
320
+ ),
321
+ open
322
+ ? React.createElement('div', { className: 'drw-row-body' },
323
+ raw !== ''
324
+ ? React.createElement(React.Fragment, null,
325
+ React.createElement('div', { className: 'drw-row-label' }, '参数'),
326
+ React.createElement('pre', { className: 'drw-row-pre' }, raw),
327
+ )
328
+ : null,
329
+ output !== ''
330
+ ? React.createElement(React.Fragment, null,
331
+ React.createElement('div', { className: 'drw-row-label' }, '输出'),
332
+ React.createElement('pre', { className: 'drw-row-pre' }, output),
333
+ )
334
+ : null,
335
+ raw === '' && output === ''
336
+ ? React.createElement('div', { className: 'drw-row-pre' }, running ? '执行中…' : '无输出')
337
+ : null,
338
+ )
339
+ : null,
340
+ )
341
+ }
342
+
343
+ // ---- one think (reasoning) row inside the slide -------------------------
344
+ // A compact single-line row, collapsible to the full reasoning text — the
345
+ // same look and feel as a tool-call row (think and tool calls are both
346
+ // "implementation" and should read the same inside the window).
347
+ function ThinkRow(props) {
348
+ var text = props.text
349
+ var index = props.index
350
+ var openState = React.useState(false)
351
+ var open = openState[0]
352
+ var setOpen = openState[1]
353
+ var firstLine = text.split('\n')[0].trim() || text.trim()
354
+ var summary = firstLine.length > 120 ? firstLine.slice(0, 117) + '…' : firstLine
355
+ return React.createElement('div', { className: 'drw-think', 'data-open': open ? '1' : undefined },
356
+ React.createElement('div', {
357
+ className: 'drw-think-head', role: 'button', tabIndex: 0,
358
+ 'aria-expanded': open || undefined,
359
+ onClick: function () { setOpen(!open) },
360
+ onKeyDown: function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(!open) } },
361
+ },
362
+ React.createElement('span', { className: 'drw-think-name' }, 'Think ' + (index + 1)),
363
+ React.createElement('span', { className: 'drw-think-summary', title: firstLine }, summary),
364
+ React.createElement('span', { className: 'drw-think-toggle', 'aria-hidden': true }, open ? '\u25BE' : '\u25B8'),
365
+ ),
366
+ open
367
+ ? React.createElement('pre', { className: 'drw-think-body' }, text)
368
+ : null,
369
+ )
370
+ }
371
+
372
+ // ---- the per-turn slide -------------------------------------------------
373
+ // One slide = one implementation segment: think + tool calls kept in their
374
+ // exact output order (not think-on-top / bash-on-bottom), all inside the
375
+ // same bounded scroll window.
376
+ function TurnSlide(props) {
377
+ var items = props.items || []
378
+ var turn = props.turn
379
+ var segmentIndex = props.segmentIndex
380
+ var segmentCount = props.segmentCount
381
+ var cwd = props.cwd
382
+ var openFile = props.openFile
383
+ var inspectCall = props.inspectCall
384
+ var cfg = props.config
385
+ var lines = useLiveLines() // live window size from Settings
386
+ var toolItems = items.filter(function (it) { return it.kind === 'tool' })
387
+ var reasoningCount = items.reduce(function (acc, it) { return it.kind === 'think' ? acc + 1 : acc }, 0)
388
+ var stats = React.useMemo(function () {
389
+ return computeStats(toolItems.map(function (it) { return it.node.data.root }))
390
+ }, [items])
391
+ var openState = React.useState(!cfg.collapsed)
392
+ var open = openState[0]
393
+ var setOpen = openState[1]
394
+ var bodyRef = React.useRef(null)
395
+ var running = stats.running > 0
396
+ // Auto-follow to the bottom while calls are still running.
397
+ React.useEffect(function () {
398
+ if (!open || !bodyRef.current) return
399
+ if (running) bodyRef.current.scrollTop = bodyRef.current.scrollHeight
400
+ })
401
+ var list = items
402
+ if (!cfg.showReadOnly) {
403
+ list = items.filter(function (it) {
404
+ return it.kind === 'think' || !READONLY_TOOLS.has(callName(it.node.data.root))
405
+ })
406
+ }
407
+ var thinkSeen = 0
408
+ return React.createElement('div', { className: 'drw-slide', 'data-running': running ? '1' : undefined },
409
+ React.createElement('div', {
410
+ className: 'drw-head', role: 'button', tabIndex: 0,
411
+ 'aria-expanded': open || undefined,
412
+ onClick: function () { setOpen(!open) },
413
+ onKeyDown: function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(!open) } },
414
+ },
415
+ React.createElement('span', { className: 'drw-head-title' },
416
+ stats.total + ' 个工具调用'
417
+ + (reasoningCount > 0 ? ' · ' + reasoningCount + ' Think' : '')
418
+ ),
419
+ running
420
+ ? React.createElement('span', { className: 'drw-head-badge', 'data-state': 'running' }, '进行中')
421
+ : null,
422
+ !running && stats.errors > 0
423
+ ? React.createElement('span', { className: 'drw-head-badge', 'data-state': 'error' }, '⚠ ' + stats.errors)
424
+ : null,
425
+ !cfg.showReadOnly && stats.readOnly > 0
426
+ ? React.createElement('span', { className: 'drw-head-badge' }, '隐藏只读 ' + stats.readOnly)
427
+ : null,
428
+ React.createElement('span', { className: 'drw-head-toggle' }, open ? '收起 ▴' : '展开 ▾'),
429
+ ),
430
+ React.createElement('div', {
431
+ ref: bodyRef,
432
+ className: 'drw-body' + (open ? '' : ' drw-collapsed'),
433
+ style: open && lines > 0 ? { maxHeight: (lines * 1.55) + 'em' } : undefined,
434
+ },
435
+ list.map(function (it) {
436
+ if (it.kind === 'think') {
437
+ var tid = thinkSeen
438
+ thinkSeen += 1
439
+ return React.createElement(ThinkRow, { key: 'think-' + tid, text: it.text, index: tid })
440
+ }
441
+ return React.createElement(SimpleToolRow, {
442
+ key: it.node.key,
443
+ block: it.node.data.root,
444
+ cwd: cwd,
445
+ openFile: openFile,
446
+ inspectCall: inspectCall,
447
+ })
448
+ }),
449
+ ),
450
+ )
451
+ }
452
+
453
+ // ---- response-segment grouping -------------------------------------------
454
+ // A "staged response" is an assistant step that returns visible text to the
455
+ // user mid-turn. Each chunk of implementation (think + tool calls) between
456
+ // two staged responses becomes its own slide — not one giant slide per user
457
+ // prompt pair.
458
+ function hasVisibleTextBlock(blocks) {
459
+ if (!blocks) return false
460
+ for (var i = 0; i < blocks.length; i++) {
461
+ var b = blocks[i]
462
+ if (b && b.kind === 'text' && typeof b.text === 'string' && b.text.trim() !== '') return true
463
+ }
464
+ return false
465
+ }
466
+
467
+ function buildTurnSegments(order, chat) {
468
+ var segments = []
469
+ var current = null
470
+ function ensure() {
471
+ if (current === null) {
472
+ current = { items: [], tools: [], startKey: null }
473
+ segments.push(current)
474
+ }
475
+ return current
476
+ }
477
+ function closeSegment() { current = null }
478
+ for (var i = 0; i < order.length; i++) {
479
+ var n = chat.nodes.get(order[i])
480
+ if (n === undefined) continue
481
+ if (n.kind === 'user') { closeSegment(); continue }
482
+ if (n.kind === 'assistant-step') {
483
+ var blocks = n.data && Array.isArray(n.data.blocks) ? n.data.blocks : []
484
+ var staged = hasVisibleTextBlock(blocks)
485
+ // The reasoning of this step belongs to the chunk leading up to the
486
+ // staged response (i.e. the segment that ends here), and it keeps its
487
+ // exact output position among the tools.
488
+ for (var b = 0; b < blocks.length; b++) {
489
+ var block = blocks[b]
490
+ if (block && block.kind === 'reasoning' && typeof block.text === 'string' && block.text.trim() !== '') {
491
+ ensure().items.push({ kind: 'think', text: block.text })
492
+ }
493
+ }
494
+ if (staged) closeSegment()
495
+ continue
496
+ }
497
+ if (n.kind === 'tool-call') {
498
+ var seg = ensure()
499
+ if (seg.startKey === null) seg.startKey = n.key
500
+ seg.items.push({ kind: 'tool', node: n })
501
+ seg.tools.push(n)
502
+ continue
503
+ }
504
+ }
505
+ // Only segments that actually have tool calls get a slide (that is where
506
+ // the slot shadow mounts); think-only chunks stay native.
507
+ return segments.filter(function (s) { return s.tools.length > 0 })
508
+ }
509
+
510
+ // ---- slot component: one slide per response segment, siblings null ------
511
+ function ToolTurnSlide(props) {
512
+ var node = props.node
513
+ // Standard session-scope prop of the slot runtime (the per-conversation
514
+ // chat snapshot hook). Guarded to a no-op so the component can never
515
+ // crash when an old/new mix of the shell omits it.
516
+ var useChat = typeof props.useChat === 'function'
517
+ ? props.useChat
518
+ : function (selector) { return { turn: turnNumber(node), segments: [] } }
519
+ var cwd = props.cwd
520
+ var openFile = props.openFile
521
+ var inspectCall = props.inspectCall
522
+ var turn = turnNumber(node)
523
+ var EMPTY = { turn: turn, segments: [] }
524
+ var snapData = useChat(function (chat) {
525
+ if (turn === undefined || !chat || !chat.locations || !chat.nodes) return EMPTY
526
+ var order = chat.locations.getTurn(turn)
527
+ return { turn: turn, segments: buildTurnSegments(order, chat) }
528
+ })
529
+ var segments = snapData.segments
530
+ try {
531
+ if (!segments || segments.length === 0) return null
532
+ var segIndex = -1
533
+ for (var i = 0; i < segments.length; i++) {
534
+ var seg = segments[i]
535
+ // Render on the segment's first tool-call node that is actually
536
+ // visible; a turn-process fold can mark leading member nodes hidden,
537
+ // and the slide must ride a row the user can see. Fall back to the
538
+ // plain segment start.
539
+ var anchor = null
540
+ for (var t = 0; t < seg.tools.length; t++) {
541
+ var toolNode = seg.tools[t]
542
+ if (toolNode.visibility !== 'hidden') { anchor = toolNode.key; break }
543
+ }
544
+ if (anchor === null) anchor = seg.startKey
545
+ if (anchor === node.key) { segIndex = i; break }
546
+ }
547
+ if (segIndex < 0) return null
548
+ var seg = segments[segIndex]
549
+ return React.createElement(TurnSlide, {
550
+ items: seg.items,
551
+ turn: turn,
552
+ segmentIndex: segments.length > 1 ? segIndex : undefined,
553
+ segmentCount: segments.length,
554
+ cwd: cwd,
555
+ openFile: openFile,
556
+ inspectCall: inspectCall,
557
+ config: currentConfig,
558
+ })
559
+ } catch (e) {
560
+ return null
561
+ }
562
+ }
563
+
564
+ // ---- DOM housekeeping ----------------------------------------------------
565
+ // A per-chat-snapshot index: assistant-step node key → whether that step
566
+ // carries visible staged text. Cached per snapshot identity so the
567
+ // `useChat` selector result stays referentially stable between updates.
568
+ var NO_TEXT = Object.freeze(new Map())
569
+ var textIndexCache = new WeakMap()
570
+ function hasTextIndexOf(chat) {
571
+ if (!chat || !chat.nodes) return NO_TEXT
572
+ var cached = textIndexCache.get(chat)
573
+ if (cached !== undefined) return cached
574
+ var map = new Map()
575
+ var nodes = chat.nodes.values()
576
+ for (var i = 0; i < nodes.length; i++) {
577
+ var n = nodes[i]
578
+ if (n && n.kind === 'assistant-step' && hasVisibleTextBlock(n.data && n.data.blocks)) {
579
+ map.set(n.key, true)
580
+ }
581
+ }
582
+ textIndexCache.set(chat, map)
583
+ return map
584
+ }
585
+
586
+ // Leftover ~0-height placeholders: for every tool-call node that is not a
587
+ // segment anchor our slot renders null, but DSH still keeps an (almost)
588
+ // empty flow item per stubbed tool call. The shell hides truly empty flow
589
+ // items by its own CSS; this scan also hides assistant-step rows that only
590
+ // held a slide-hidden Think (zero visible height, but still occupying the
591
+ // flow gap slot between the slide and the next response). Never stub while
592
+ // the row is streaming (content may come), and un-stub as soon as the chat
593
+ // snapshot reports staged text for that row's node (keyed via
594
+ // `data-chat-flow-key` — no CSS-class sniffing, which broke in the alpha
595
+ // that hashed module classes).
596
+ function markStubs(root, textIndex) {
597
+ // Tool-call placeholders: every non-anchor tool-call renders an (almost)
598
+ // empty flow item from our null slot.
599
+ var rows = root.querySelectorAll('[data-chat-flow-kind="tool-call"]')
600
+ for (var i = 0; i < rows.length; i++) {
601
+ var e = rows[i]
602
+ var isStub = !e.querySelector('.drw-slide') && (e.textContent || '').trim() === ''
603
+ if (isStub) e.dataset.drwStub = '1'
604
+ else if (e.dataset.drwStub) delete e.dataset.drwStub
605
+ }
606
+ // Assistant-step rows that only held a reasoning block: once the native
607
+ // think is folded into the slide (hidden), the whole row is 0-height but
608
+ // still occupies a flex gap slot between the slide and the next response.
609
+ // Hide such ghost rows too — but never while streaming (content may come).
610
+ var steps = root.querySelectorAll('[data-chat-flow-kind="assistant-step"]')
611
+ for (var j = 0; j < steps.length; j++) {
612
+ var s = steps[j]
613
+ if (s.querySelector('.drw-slide')) { if (s.dataset.drwStub) delete s.dataset.drwStub; continue }
614
+ if (s.querySelector('[data-streaming]')) { if (s.dataset.drwStub) delete s.dataset.drwStub; continue }
615
+ var hasHiddenThink = !!s.querySelector('[data-variant="think"][data-drw-hidethink="1"]')
616
+ var key = s.getAttribute('data-chat-flow-key')
617
+ var hasText = key === null || textIndex.get(key) === true
618
+ if (hasHiddenThink && !hasText) s.dataset.drwStub = '1'
619
+ else if (s.dataset.drwStub) delete s.dataset.drwStub
620
+ }
621
+ }
622
+
623
+ // Hide native Think rows that belong to a turn with a slide, so the same
624
+ // reasoning does not appear both inside the slide and inline in the flow.
625
+ // Only DOM class toggling — never reparents React-owned rows.
626
+ function hideNativeThink(root, textIndex) {
627
+ var flow = root.querySelector('[data-chat-flow]')
628
+ if (!flow || !flow.children) return
629
+ var rows = []
630
+ for (var i = 0; i < flow.children.length; i++) rows.push(flow.children[i])
631
+ // Segment boundary in the DOM: a user/steering row, a turn-level row
632
+ // (turn-process / turn-tail), or an assistant-step row that carries
633
+ // visible staged text (a response shown to the user mid-turn).
634
+ function isSegmentBoundary(row) {
635
+ if (!row.matches) return false
636
+ if (row.matches('[data-chat-flow-kind="user"]')) return true
637
+ var kind = row.getAttribute('data-chat-flow-kind')
638
+ if (kind === 'steering' || kind === 'turn-tail' || kind === 'turn-process') return true
639
+ if (kind !== 'assistant-step') return false
640
+ var key = row.getAttribute('data-chat-flow-key')
641
+ return key === null || textIndex.get(key) === true
642
+ }
643
+ for (var r = 0; r < rows.length; r++) {
644
+ var row = rows[r]
645
+ if (!row || !row.querySelector) continue
646
+ var think = row.matches && row.matches('[data-variant="think"]') ? row : row.querySelector('[data-variant="think"]')
647
+ if (!think) continue
648
+ var hasSlide = false
649
+ for (var j = r - 1; j >= 0; j--) {
650
+ var prev = rows[j]
651
+ if (!prev || !prev.matches) continue
652
+ if (isSegmentBoundary(prev)) break
653
+ if (prev.querySelector && prev.querySelector('.drw-slide')) { hasSlide = true; break }
654
+ }
655
+ for (var k = r + 1; k < rows.length && !hasSlide; k++) {
656
+ var next = rows[k]
657
+ if (!next || !next.matches) continue
658
+ if (isSegmentBoundary(next)) break
659
+ if (next.querySelector && next.querySelector('.drw-slide')) { hasSlide = true; break }
660
+ }
661
+ if (hasSlide) think.dataset.drwHidethink = '1'
662
+ else if (think.dataset.drwHidethink) delete think.dataset.drwHidethink
663
+ }
664
+ }
665
+
666
+ // Invisible per-session dock that watches the conversation scroller and
667
+ // keeps native Think rows in sync with the slides (session-scoped).
668
+ function TextWindowDock(props) {
669
+ var useChat = typeof props.useChat === 'function' ? props.useChat : null
670
+ var textIndex = useChat === null ? NO_TEXT : useChat(hasTextIndexOf)
671
+ var textIndexRef = React.useRef(NO_TEXT)
672
+ React.useEffect(function () { textIndexRef.current = textIndex }, [textIndex])
673
+ var mountRef = React.useRef(null)
674
+ React.useEffect(function () {
675
+ var mount = mountRef.current
676
+ if (!mount) return
677
+ var scroller = mount.closest('[data-conversation-scroll]')
678
+ var root = scroller || document.body
679
+ var timer = null
680
+ var bootTimer = null
681
+ var scan = function () {
682
+ try { markStubs(root, textIndexRef.current) } catch (e) {}
683
+ try { hideNativeThink(root, textIndexRef.current) } catch (e) {}
684
+ }
685
+ // Session switch renders the new rows in bursts; a debounce makes the
686
+ // stub hiding lag behind by hundreds of ms, during which the 0-height
687
+ // placeholder rows inflate the gap below a slide. Scan on the same
688
+ // animation frame the mutation lands (throttled to one per frame), so
689
+ // the fix is in place before the next paint — no visible flicker.
690
+ var schedule = function () {
691
+ if (timer != null) return
692
+ timer = requestAnimationFrame(function () {
693
+ timer = null
694
+ scan()
695
+ // trailing pass: rows that landed between the mutation and paint
696
+ window.setTimeout(scan, 80)
697
+ })
698
+ }
699
+ scan()
700
+ var observer = new MutationObserver(function () { schedule() })
701
+ observer.observe(root, { childList: true, subtree: true })
702
+ // Re-scan shortly after mount for already-settled history.
703
+ bootTimer = setTimeout(scan, 200)
704
+ return function () {
705
+ observer.disconnect()
706
+ if (timer != null) cancelAnimationFrame(timer)
707
+ clearTimeout(bootTimer)
708
+ }
709
+ }, [props.sessionId])
710
+ return React.createElement('div', { ref: mountRef, 'data-drw-dock': '', style: { display: 'none' } })
711
+ }
712
+
713
+ // ---- Settings row: response window size ---------------------------------
714
+ function ResponseWindowSettingsRow(props) {
715
+ var useLines = props.useLines
716
+ var setLines = props.setLines
717
+ var lines = (typeof useLines === 'function' ? useLines(function (s) { return s }) : null) ?? currentConfig.lines
718
+ var value = clampLines(lines)
719
+ var step = function (delta) {
720
+ setLines(value + delta)
721
+ }
722
+ return React.createElement('div', { className: 'drw-set-row' },
723
+ React.createElement('div', { className: 'drw-set-text' },
724
+ React.createElement('div', { className: 'drw-set-title' }, '响应窗口大小(行数)'),
725
+ React.createElement('div', { className: 'drw-set-desc' }, '每个响应 slide 的限高滚动窗口。默认 10 行,0 = 不限高,改动即时生效。'),
726
+ ),
727
+ React.createElement('div', { className: 'drw-set-control' },
728
+ React.createElement('button', {
729
+ type: 'button', className: 'drw-set-btn', 'aria-label': '减小窗口',
730
+ disabled: value <= 0,
731
+ onClick: function () { step(-1) },
732
+ }, '−'),
733
+ React.createElement('input', {
734
+ type: 'number', className: 'drw-set-input', min: 0, max: 200, step: 1, value: String(value),
735
+ onChange: function (e) { setLines(e.target.value) },
736
+ }),
737
+ React.createElement('button', {
738
+ type: 'button', className: 'drw-set-btn', 'aria-label': '增大窗口',
739
+ disabled: value >= 200,
740
+ onClick: function () { step(1) },
741
+ }, '+'),
742
+ ),
743
+ )
744
+ }
745
+
746
+ // ---- plugin entry -------------------------------------------------------
747
+ var currentConfig = readConfig(null)
748
+
749
+ var INJECT = ['slots', 'settingsScope']
750
+ function apply(ctx, config) {
751
+ currentConfig = readConfig(config)
752
+ injectStyles()
753
+
754
+ // Durable settings binding: the host half registered the `dsh-newbe-response-window`
755
+ // namespace; the browser scope mirrors it and persists user overrides.
756
+ linesStore = createSnapshotStore(currentConfig.lines)
757
+ var scopeService = null
758
+ try { scopeService = ctx.get('settingsScope') } catch (e) { scopeService = null }
759
+ if (scopeService && typeof scopeService.bind === 'function') {
760
+ try {
761
+ settingsScope = scopeService.bind({ namespace: NS })
762
+ } catch (e) {
763
+ settingsScope = null
764
+ }
765
+ }
766
+ if (settingsScope !== null) {
767
+ var adopt = function () {
768
+ try {
769
+ var snap = settingsScope.getSnapshot()
770
+ var value = snap && snap.value
771
+ if (value && typeof value.lines === 'number') {
772
+ var n = clampLines(value.lines)
773
+ if (linesStore !== null && linesStore.getSnapshot() !== n) linesStore.set(n)
774
+ }
775
+ } catch (e) {}
776
+ }
777
+ settingsScope.subscribe(adopt)
778
+ adopt()
779
+ }
780
+
781
+ ctx.slots.inject('conversation.chat.node', function () {
782
+ return ctx.slots.register({
783
+ name: 'conversation.chat.node',
784
+ key: 'tool-call',
785
+ priority: -100,
786
+ }, ToolTurnSlide)
787
+ })
788
+
789
+ // Always mounted: hides native Think rows (core feature) and optionally
790
+ // applies the bounded window to long assistant markdown.
791
+ ctx.slots.inject('conversation.input.dock', function () {
792
+ return ctx.slots.register({
793
+ name: 'conversation.input.dock',
794
+ id: 'dsh-newbe-response-window',
795
+ order: 90,
796
+ }, TextWindowDock)
797
+ })
798
+
799
+ // Settings → General row for the window size (live, persisted).
800
+ ctx.slots.inject('settings.general.item', function () {
801
+ return ctx.slots.register({
802
+ name: 'settings.general.item',
803
+ id: 'dsh-newbe-response-window',
804
+ order: 60,
805
+ inject: function () {
806
+ return {
807
+ hooks: { lines: linesStore },
808
+ setLines: function (value) {
809
+ var n = clampLines(value)
810
+ if (linesStore !== null) linesStore.set(n)
811
+ if (settingsScope !== null) {
812
+ settingsScope.set('lines', n).catch(function () {})
813
+ }
814
+ },
815
+ }
816
+ },
817
+ }, ResponseWindowSettingsRow)
818
+ })
819
+ }
820
+
821
+ exports.apply = apply
822
+ exports.inject = INJECT
823
+ Object.defineProperty(exports, '__esModule', { value: true })
824
+ return module.exports
825
+ },
826
+ })
package/lib/index.js ADDED
@@ -0,0 +1,42 @@
1
+ // dsh-newbe-response-window — host half.
2
+ //
3
+ // Verified against DSH 0.1.2-alpha.5. Registers the durable settings namespace
4
+ // so the browser Settings UI can persist the window size etc. in the user
5
+ // settings document. The heavy lifting lives in the browser half (exports
6
+ // "./client").
7
+ //
8
+ // Namespace + schema (browser `settingsScope.bind` mirrors it):
9
+ // lines window height in lines (default 10; 0 = uncapped)
10
+ // collapsed start slides collapsed instead of always-expanded
11
+ // showReadOnly include read-only tools in the slide list
12
+ // minCollapseRows min tool-call count for auto-collapse (collapsed mode)
13
+ //
14
+ // `settingsNamespace()` is just a validated string (kebab-case pattern); the
15
+ // namespace below already matches, so we pass the raw string and avoid an extra
16
+ // host dependency.
17
+
18
+ import z from '@deepseek-ai/schemastery'
19
+
20
+ /** Stable Cordis plugin name. */
21
+ export const name = 'dsh-newbe-response-window'
22
+
23
+ /** Durable settings namespace for this plugin (lowercase kebab-case). */
24
+ export const SETTINGS_NS = 'dsh-newbe-response-window'
25
+
26
+ /** Settings schema, shared with the browser scope and any Settings UI. */
27
+ export const Config = z.object({
28
+ lines: z.natural().min(0).max(200).default(10),
29
+ collapsed: z.boolean().default(false),
30
+ showReadOnly: z.boolean().default(true),
31
+ minCollapseRows: z.natural().min(1).max(50).default(3),
32
+ })
33
+
34
+ /**
35
+ * Register the durable settings namespace when a settings provider exists.
36
+ * @param ctx - host plugin context.
37
+ */
38
+ export function apply(ctx) {
39
+ ctx.inject(['settings'], (settingsCtx) => {
40
+ settingsCtx.settings.register(SETTINGS_NS, Config)
41
+ })
42
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "dsh-newbe-response-window",
3
+ "version": "0.2.0",
4
+ "description": "DeepSeek Harness (DSH) web plugin: per-turn response window. Each turn's tool calls collapse into ONE always-expanded bounded slide (configurable N-line window, inner scroll, nothing hidden), and long assistant markdown gets a bounded scroll window too — Grok-build style, keeps every intermediate response visible inside a finite window.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "dsh": {
13
+ "bundle": {
14
+ "patch": "./cordis.patch.yml"
15
+ },
16
+ "client": {
17
+ "platform": "web"
18
+ }
19
+ },
20
+ "files": [
21
+ "lib/index.js",
22
+ "lib/client.js",
23
+ "cordis.patch.yml",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "scripts": {
28
+ "check": "node --check lib/index.js && node --check lib/client.js"
29
+ },
30
+ "dependencies": {
31
+ "@deepseek-ai/schemastery": "^3.0.0"
32
+ },
33
+ "peerDependencies": {
34
+ "react": "^18.0.0 || ^19.0.0"
35
+ },
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/qiqiangvae/dsh-newbe-plugins.git",
40
+ "directory": "packages/dsh-newbe-response-window"
41
+ },
42
+ "homepage": "https://github.com/qiqiangvae/dsh-newbe-plugins/tree/main/packages/dsh-newbe-response-window#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/qiqiangvae/dsh-newbe-plugins/issues"
45
+ },
46
+ "keywords": [
47
+ "dsh-plugin",
48
+ "deepseek-harness",
49
+ "dsh",
50
+ "response-window",
51
+ "tool-call",
52
+ "slide",
53
+ "web-ui"
54
+ ]
55
+ }