dsh-code-server-app 0.3.21 → 0.3.22

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.
Files changed (42) hide show
  1. package/README.en.md +77 -18
  2. package/README.md +70 -20
  3. package/assets/extensions/dshcs-editor-bridge/extension.js +83 -47
  4. package/assets/extensions/dshcs-editor-bridge/lib/ask-panel.js +343 -174
  5. package/assets/extensions/dshcs-editor-bridge/lib/bridge-client.js +30 -4
  6. package/assets/extensions/dshcs-editor-bridge/package.json +2 -2
  7. package/assets/extensions/dshcs-editor-bridge/webview/THIRD-PARTY.md +21 -0
  8. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
  9. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
  10. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
  11. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
  12. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
  13. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
  14. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
  15. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
  16. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
  17. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
  18. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
  19. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
  20. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
  21. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
  22. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
  23. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
  24. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
  25. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
  26. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
  27. package/assets/extensions/dshcs-editor-bridge/webview/fonts/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
  28. package/assets/extensions/dshcs-editor-bridge/webview/src/app.jsx +189 -0
  29. package/assets/extensions/dshcs-editor-bridge/webview/src/approval.jsx +53 -0
  30. package/assets/extensions/dshcs-editor-bridge/webview/src/official-tokens.css +8 -0
  31. package/assets/extensions/dshcs-editor-bridge/webview/src/panel.css +283 -0
  32. package/assets/extensions/dshcs-editor-bridge/webview/src/thread.jsx +72 -0
  33. package/assets/extensions/dshcs-editor-bridge/webview/thread.css +1 -0
  34. package/assets/extensions/dshcs-editor-bridge/webview/thread.js +462 -0
  35. package/lib/bridge-approval.mjs +189 -0
  36. package/lib/bridge-session.mjs +3 -1
  37. package/lib/bridge-thread.mjs +345 -0
  38. package/lib/bridge.mjs +15 -6
  39. package/lib/index.js +142 -17
  40. package/package.json +29 -6
  41. package/vendor/VENDOR.json +1 -1
  42. package/lib/bridge-answer.mjs +0 -155
@@ -1,94 +1,258 @@
1
- // dshcs-editor-bridge / lib/ask-panel.js —— 「问 DSH」面板(0.2.0)
1
+ // dshcs-editor-bridge / lib/ask-panel.js —— 「问 DSH」面板的纯逻辑(0.2.3)
2
2
  //
3
- // 纯逻辑 + HTML 生成,**不 require('vscode')**,所以能在普通 Node 里单测
4
- // (scripts/test-bridge-extension.mjs)。与 VS Code 交互的部分在 extension.js。
3
+ // **一份模型,两边用**:
4
+ // - 扩展侧(extension.js)用它保存面板状态,并组装每趟轮询 postMessage 的载荷;
5
+ // - 面板侧(webview/src/app.jsx,由 scripts/build-webview.mjs 打成 webview/thread.js)
6
+ // 用它把载荷变成视图 —— 消息正文交给 DSH **官方** markdown 渲染器
7
+ // (`@deepseek-ai/dsh-client-ui-primitives` 的 MarkdownText,与 DSH 界面同一份代码)。
5
8
  //
6
- // 为什么要有这个面板:0.3.0–0.3.18 的提问是「输入框 → 发送 → 一条状态栏提示」,
7
- // 用户在编辑器里问完必须切回 DSH 界面才看得到回答。面板把三件事放在一处:
8
- // ① 带着上下文提问(文件:行 + 选中内容,发送时再取一次当前选区);
9
- // ② 提问以**用户输入**的形态进 DSH 会话(host 侧 bridge-session.mjs 的 source.kind='user');
10
- // ③ 回答**同步显示在这里** —— host 把 agent 的正文随 /sync 的 answers 字段推回来
11
- // (见 lib/bridge-answer.mjs),面板每趟轮询刷新一次,不需要第二个定时器。
9
+ // 0.2.0–0.2.2 的面板自己拼 HTML 字符串、正文按 `white-space: pre-wrap` 原样显示,与 DSH 界面
10
+ // 的排版不一致;0.2.3 起正文走官方渲染器,面板只保留"外壳"逻辑(上下文 / 状态 / 输入框)。
11
+ //
12
+ // 本文件不 require('vscode')、不碰 DOM,所以能在普通 Node 里单测
13
+ // (scripts/test-bridge-extension.mjs)。
12
14
 
13
15
  'use strict';
14
16
 
15
- /** 面板里最多保留多少轮问答(超过丢最旧的:这是对话窗口,不是存档)。 */
16
- const MAX_TURNS = 20;
17
+ /** 面板保留的对话条目上限(宿主侧 lib/bridge-thread.mjs 也有上限;这里再兜一层)。 */
18
+ const MAX_ENTRIES = 200;
17
19
 
18
- /** 单条消息渲染上限(回答太长时截断,避免 webview 卡)。 */
19
- const MAX_MESSAGE_CHARS = 20000;
20
+ /** 本地乐观提问(已发出、宿主还没回显)的上限。 */
21
+ const MAX_PENDING = 6;
20
22
 
21
- /** HTML 转义:面板里所有用户/模型文本都必须走它(webview 里就是 XSS 面)。 */
22
- function escapeHtml(text) {
23
- return String(text)
24
- .replace(/&/g, '&')
25
- .replace(/</g, '&lt;')
26
- .replace(/>/g, '&gt;')
27
- .replace(/"/g, '&quot;')
28
- .replace(/'/g, '&#39;');
23
+ /** 单条正文渲染上限(宿主已截到 8000,这里只防意外)。 */
24
+ const MAX_TEXT = 20000;
25
+
26
+ /** 授权卡片的默认等待窗口(毫秒);宿主随 /sync 给出真实值,这里只是兜底。 */
27
+ const DEFAULT_HOLD_MS = 8000;
28
+
29
+ /** 条目角色(与 lib/bridge-thread.mjs 的投影一致)。 */
30
+ const ROLES = ['user', 'assistant', 'tool', 'approval'];
31
+
32
+ /** 折叠空白:用于"本地乐观提问"与"宿主回显"的同一性比较。 */
33
+ function normalizeText(text) {
34
+ return String(text ?? '').replace(/\s+/g, ' ').trim();
35
+ }
36
+
37
+ /** 面板状态(纯数据,便于单测)。 */
38
+ function createPanelState() {
39
+ return {
40
+ /** 对话条目(宿主权威;0.3.22 起只含**新内容**)。 */
41
+ entries: [],
42
+ /** 条目签名:变了才刷新面板(600ms 一趟轮询,不能每趟都重传几十 KB)。 */
43
+ entriesSig: '',
44
+ /** 已发出、宿主还没回显的提问(乐观显示)。 */
45
+ pending: [],
46
+ /** 待决授权请求 [{id, toolName, reason, callId, at}]。 */
47
+ approvals: [],
48
+ approvalSig: '',
49
+ approvalHoldMs: DEFAULT_HOLD_MS,
50
+ /** 宿主有没有对话流能力:null=还不知道,false=旧版宿主。 */
51
+ available: null,
52
+ /** 对话流的错误(订阅失败 / 旧版宿主)。 */
53
+ threadError: null,
54
+ /** 当前提问意图:'selection' | 'file'。 */
55
+ mode: 'selection',
56
+ /** 发送时那一次取的编辑器上下文。 */
57
+ context: null,
58
+ /** idle | sending | thinking | error */
59
+ status: 'idle',
60
+ error: null,
61
+ /** 面板绑定的会话(host 回话里的 sessionId)。 */
62
+ sessionId: null,
63
+ /** DSH 界面的版本(host 的 /sync 报回来;面板据此提示渲染器版本不一致)。 */
64
+ uiVersion: null,
65
+ };
66
+ }
67
+
68
+ /** 一条宿主条目 → 规整过的渲染条目(字段白名单,不接受宿主塞别的东西)。 */
69
+ function cleanEntry(raw) {
70
+ if (raw === null || typeof raw !== 'object') return null;
71
+ const role = ROLES.includes(raw.role) ? raw.role : null;
72
+ if (role === null) return null;
73
+ const text = typeof raw.text === 'string' ? raw.text.slice(0, MAX_TEXT) : '';
74
+ return {
75
+ role,
76
+ text,
77
+ streaming: raw.streaming === true,
78
+ name: typeof raw.name === 'string' ? raw.name : null,
79
+ summary: typeof raw.summary === 'string' ? raw.summary : null,
80
+ status: typeof raw.status === 'string' ? raw.status : null,
81
+ callId: typeof raw.callId === 'string' ? raw.callId : null,
82
+ approvalId: typeof raw.approvalId === 'string' ? raw.approvalId : null,
83
+ };
29
84
  }
30
85
 
31
86
  /**
32
- * 面板状态模型(纯数据,便于单测)。
33
- * @returns {{turns: Array<{question: string, answer: string, done: boolean, error: string|null}>,
34
- * context: object|null, status: string, sessionId: string|null}}
87
+ * 条目签名:正文只会"流式变长"或"被耐久消息原地替换"(此时 streaming 翻转),
88
+ * 所以 角色 + 状态 + 长度 + 是否流式 足够判变化,不必每趟比对几十 KB 文本。
35
89
  */
36
- function createPanelState() {
37
- return { turns: [], context: null, status: 'idle', sessionId: null };
90
+ function entriesSignature(entries) {
91
+ const parts = [];
92
+ for (const entry of entries) {
93
+ parts.push(`${entry.role}:${entry.status ?? ''}:${entry.streaming ? 1 : 0}:${entry.text.length}:${entry.summary === null ? '' : entry.summary.length}`);
94
+ }
95
+ return `${entries.length}|${parts.join(',')}`;
38
96
  }
39
97
 
40
- /** 追加一次提问(开始新一轮)。 */
41
- function pushQuestion(state, question, context) {
42
- state.turns.push({ question, answer: '', done: false, error: null });
43
- while (state.turns.length > MAX_TURNS) state.turns.shift();
44
- state.context = context ?? state.context;
45
- state.status = 'sending';
46
- state.sessionId = null;
47
- return state.turns[state.turns.length - 1];
98
+ /** 本地乐观提问 → 渲染条目。 */
99
+ function pendingEntries(state) {
100
+ return state.pending.map((item) => ({
101
+ role: 'user',
102
+ text: item.text,
103
+ streaming: false,
104
+ name: null,
105
+ summary: null,
106
+ status: item.error === null || item.error === undefined ? 'sending' : 'error',
107
+ callId: null,
108
+ approvalId: null,
109
+ }));
48
110
  }
49
111
 
50
- /** 最后一次提问对应的轮次(没有就返回 null)。 */
51
- function currentTurn(state) {
52
- return state.turns.length === 0 ? null : state.turns[state.turns.length - 1];
112
+ /** 面板要渲染的完整列表(宿主条目 + 尚未回显的本地提问)。 */
113
+ function entriesOf(state) {
114
+ const all = [...state.entries, ...pendingEntries(state)];
115
+ return all.length > MAX_ENTRIES ? all.slice(all.length - MAX_ENTRIES) : all;
53
116
  }
54
117
 
55
118
  /**
56
- * 把 host 推回来的 answers 应用到状态上。
57
- * `answers` 是 `[{sessionId, text, done, at}]`(见 lib/bridge-answer.mjs);只认**本面板登记的会话**。
119
+ * 应用宿主 `/sync` 的 `thread` 快照。
120
+ *
121
+ * `thread === null` = 旧版宿主(没有这个字段)—— 必须**说出原因**,否则面板会永远停在"正在回答…"。
122
+ * `thread.available === false` = 宿主有这个能力但当前没有 watch 到会话(还没提问过)。
123
+ *
58
124
  * @returns {boolean} 是否有变化(调用方据此决定要不要刷新 webview)
59
125
  */
60
- function applyAnswers(state, answers, sessionId) {
61
- const target = sessionId ?? state.sessionId;
62
- if (target === null || target === undefined || !Array.isArray(answers)) return false;
63
- const entry = answers.find((item) => item !== null && typeof item === 'object' && item.sessionId === target);
64
- if (entry === undefined) return false;
65
- const turn = currentTurn(state);
66
- if (turn === null) return false;
67
- const text = typeof entry.text === 'string' ? entry.text.slice(0, MAX_MESSAGE_CHARS) : '';
68
- const done = entry.done === true;
69
- const changed = turn.answer !== text || turn.done !== done || state.status !== (done ? 'idle' : 'thinking');
70
- turn.answer = text;
71
- turn.done = done;
72
- state.status = done ? 'idle' : 'thinking';
126
+ function applyThread(state, thread, sessionId) {
127
+ if (thread === null || thread === undefined || typeof thread !== 'object') {
128
+ const changed = state.available !== false || state.threadError === null;
129
+ state.available = false;
130
+ state.threadError = '宿主没有对话流能力(插件版本过旧?):请重启 dsh web 让 host 侧升级到 0.3.22 以上';
131
+ // 已经问过了却拿不到对话流:不能只留一行提示,面板必须报错(否则永远停在"正在回答…")。
132
+ if (state.pending.length > 0 && state.status !== 'error') {
133
+ state.status = 'error';
134
+ state.error = state.threadError;
135
+ }
136
+ return changed;
137
+ }
138
+ let changed = false;
139
+ const next = [];
140
+ if (Array.isArray(thread.entries)) {
141
+ for (const raw of thread.entries) {
142
+ const entry = cleanEntry(raw);
143
+ if (entry !== null) next.push(entry);
144
+ }
145
+ }
146
+ const sig = entriesSignature(next);
147
+ if (sig !== state.entriesSig) {
148
+ state.entries = next;
149
+ state.entriesSig = sig;
150
+ changed = true;
151
+ }
152
+ const available = thread.available !== false;
153
+ if (available !== state.available) {
154
+ state.available = available;
155
+ changed = true;
156
+ }
157
+ const error = typeof thread.error === 'string' && thread.error !== '' ? thread.error : null;
158
+ if (error !== state.threadError) {
159
+ state.threadError = error;
160
+ changed = true;
161
+ }
162
+ const session = typeof thread.sessionId === 'string' && thread.sessionId !== ''
163
+ ? thread.sessionId
164
+ : (typeof sessionId === 'string' && sessionId !== '' ? sessionId : null);
165
+ if (session !== null && session !== state.sessionId) {
166
+ state.sessionId = session;
167
+ changed = true;
168
+ }
169
+ // 宿主回显了同一段文字 → 本地乐观条目退场(不重复显示)。
170
+ const before = state.pending.length;
171
+ state.pending = state.pending.filter((item) => !next.some(
172
+ (entry) => entry.role === 'user' && normalizeText(entry.text) === normalizeText(item.text),
173
+ ));
174
+ if (state.pending.length !== before) changed = true;
175
+
176
+ // 忙碌判定:有待回声的提问、有流式条目、或有还在跑的工具 → thinking。
177
+ const busy = state.pending.length > 0
178
+ || next.some((entry) => entry.streaming === true || (entry.role === 'tool' && entry.status === 'running'));
179
+ if (busy && state.status !== 'error') state.status = 'thinking';
180
+ else if (!busy && (state.status === 'thinking' || state.status === 'sending')) state.status = 'idle';
73
181
  return changed;
74
182
  }
75
183
 
76
- /** 状态 → 面板可渲染的 JSON(postMessage 的载荷)。 */
77
- function panelPayload(state) {
78
- return {
79
- type: 'state',
80
- status: state.status,
81
- context: state.context,
82
- turns: state.turns.map((turn) => ({
83
- question: turn.question,
84
- answer: turn.answer,
85
- done: turn.done === true,
86
- error: turn.error ?? null,
87
- })),
88
- };
184
+ /** 应用宿主 `/sync` 的 `approvals` 快照(待决授权请求)。 */
185
+ function applyApprovals(state, approvals, holdMs) {
186
+ const list = [];
187
+ if (Array.isArray(approvals)) {
188
+ for (const raw of approvals) {
189
+ if (raw === null || typeof raw !== 'object') continue;
190
+ if (typeof raw.id !== 'string' || raw.id === '') continue;
191
+ list.push({
192
+ id: raw.id,
193
+ toolName: typeof raw.toolName === 'string' && raw.toolName !== '' ? raw.toolName : 'tool',
194
+ reason: typeof raw.reason === 'string' ? raw.reason : '',
195
+ callId: typeof raw.callId === 'string' ? raw.callId : null,
196
+ at: Number.isSafeInteger(raw.at) ? raw.at : Date.now(),
197
+ });
198
+ }
199
+ list.sort((a, b) => a.at - b.at);
200
+ }
201
+ const window = Number.isSafeInteger(holdMs) && holdMs > 0 ? holdMs : DEFAULT_HOLD_MS;
202
+ const sig = `${window}|${JSON.stringify(list)}`;
203
+ if (sig === state.approvalSig) return false;
204
+ state.approvals = list;
205
+ state.approvalHoldMs = window;
206
+ state.approvalSig = sig;
207
+ return true;
208
+ }
209
+
210
+ /**
211
+ * 把宿主一趟 `/sync` 的结果并进面板状态(扩展侧只调这一个)。
212
+ *
213
+ * `result.thread === undefined` = 旧版宿主(没有这个字段)。
214
+ * @returns {boolean} 是否有变化(调用方据此决定要不要刷新 webview)
215
+ */
216
+ function applySync(state, result) {
217
+ let changed = false;
218
+ if (applyThread(state, result === null || result === undefined ? null : result.thread, result?.sessionId ?? null)) {
219
+ changed = true;
220
+ }
221
+ if (applyApprovals(state, result === null || result === undefined ? [] : result.approvals, result?.approvalHoldMs)) {
222
+ changed = true;
223
+ }
224
+ const version = result === null || result === undefined ? undefined : result.uiVersion;
225
+ if (typeof version === 'string' && version !== '' && version !== state.uiVersion) {
226
+ state.uiVersion = version;
227
+ changed = true;
228
+ }
229
+ return changed;
230
+ }
231
+
232
+ /**
233
+ * 登记一条本地提问(乐观显示;宿主回显后自动退场)。
234
+ * @returns {object} 刚登记的 pending 条目
235
+ */
236
+ function pendingQuestion(state, text, context) {
237
+ const item = { text: String(text ?? ''), at: Date.now(), error: null };
238
+ state.pending.push(item);
239
+ while (state.pending.length > MAX_PENDING) state.pending.shift();
240
+ if (context !== null && context !== undefined) state.context = context;
241
+ state.status = 'sending';
242
+ state.error = null;
243
+ return item;
89
244
  }
90
245
 
91
- /** 上下文描述(标题栏那一行):文件:行 + 选区行数。 */
246
+ /** 投递失败 / 面板错误:状态行 + 最新一条本地提问都标上原因。 */
247
+ function failPanel(state, message) {
248
+ state.status = 'error';
249
+ state.error = message;
250
+ const last = state.pending[state.pending.length - 1];
251
+ if (last !== undefined) last.error = message;
252
+ return state;
253
+ }
254
+
255
+ /** 上下文 → 一行描述(标题栏):文件:行 + 选区行数。 */
92
256
  function describeContext(context) {
93
257
  if (context === null || context === undefined) return '';
94
258
  const name = typeof context.file === 'string' && context.file !== '' ? context.file : '(当前文件)';
@@ -102,129 +266,134 @@ function describeContext(context) {
102
266
  return base;
103
267
  }
104
268
 
269
+ /** 标题栏那一行:上下文 + 提问意图。 */
270
+ function contextLine(state) {
271
+ const where = state.context === null || state.context === undefined
272
+ ? '来自编辑器'
273
+ : `来自编辑器:${describeContext(state.context)}`;
274
+ if (state.context === null || state.context === undefined) return where;
275
+ return state.mode === 'file' ? `${where} · 针对当前文件` : `${where} · 针对选中内容`;
276
+ }
277
+
278
+ /** 状态 → 状态行文案(扩展侧组装,面板只显示)。 */
279
+ function statusText(state) {
280
+ if (state.status === 'error') return state.error ?? '出错了';
281
+ if (state.status === 'sending') return '正在发给 DSH…';
282
+ if (state.status === 'thinking') return 'DSH 正在回答…';
283
+ return state.threadError ?? '';
284
+ }
285
+
286
+ /** 状态 → 面板可渲染的 JSON(postMessage 的载荷)。 */
287
+ function panelPayload(state) {
288
+ return {
289
+ type: 'state',
290
+ entries: entriesOf(state),
291
+ approvals: state.approvals,
292
+ approvalHoldMs: state.approvalHoldMs,
293
+ contextText: contextLine(state),
294
+ statusText: statusText(state),
295
+ status: state.status,
296
+ error: state.error,
297
+ sessionId: state.sessionId,
298
+ available: state.available,
299
+ threadError: state.threadError,
300
+ uiVersion: state.uiVersion,
301
+ };
302
+ }
303
+
304
+ /** 面板侧:把载荷并进本地视图状态(只认白名单字段)。 */
305
+ function applyPayload(state, payload) {
306
+ if (payload === null || typeof payload !== 'object' || payload.type !== 'state') return state;
307
+ return {
308
+ ...state,
309
+ entries: Array.isArray(payload.entries) ? payload.entries : [],
310
+ approvals: Array.isArray(payload.approvals) ? payload.approvals : [],
311
+ approvalHoldMs: Number.isSafeInteger(payload.approvalHoldMs) && payload.approvalHoldMs > 0
312
+ ? payload.approvalHoldMs
313
+ : DEFAULT_HOLD_MS,
314
+ contextText: typeof payload.contextText === 'string' ? payload.contextText : '',
315
+ statusText: typeof payload.statusText === 'string' ? payload.statusText : '',
316
+ status: typeof payload.status === 'string' ? payload.status : 'idle',
317
+ error: typeof payload.error === 'string' ? payload.error : null,
318
+ sessionId: typeof payload.sessionId === 'string' ? payload.sessionId : null,
319
+ available: payload.available === undefined ? null : payload.available,
320
+ threadError: typeof payload.threadError === 'string' ? payload.threadError : null,
321
+ uiVersion: typeof payload.uiVersion === 'string' ? payload.uiVersion : null,
322
+ };
323
+ }
324
+
325
+ /** 面板视图的初始状态(同 applyPayload 的输出形状,首帧渲染用)。 */
326
+ function createViewState() {
327
+ return {
328
+ entries: [],
329
+ approvals: [],
330
+ approvalHoldMs: DEFAULT_HOLD_MS,
331
+ contextText: '来自编辑器',
332
+ statusText: '',
333
+ status: 'idle',
334
+ error: null,
335
+ sessionId: null,
336
+ available: null,
337
+ threadError: null,
338
+ uiVersion: null,
339
+ };
340
+ }
341
+
342
+ /** HTML 属性转义(只用于外壳里的 URI;正文一律走 React,不做字符串拼接)。 */
343
+ function escapeAttribute(text) {
344
+ return String(text)
345
+ .replace(/&/g, '&amp;')
346
+ .replace(/</g, '&lt;')
347
+ .replace(/>/g, '&gt;')
348
+ .replace(/"/g, '&quot;')
349
+ .replace(/'/g, '&#39;');
350
+ }
351
+
105
352
  /**
106
- * 面板 HTML(一次性的外壳;之后靠 postMessage 增量更新,避免把用户正在输入的内容冲掉)。
107
- * @param {{cspSource: string, nonce: string}} options
353
+ * 面板 HTML(一次性外壳):只加载打包好的 thread.js / thread.css,
354
+ * 之后全靠 postMessage 更新 —— 输入框与滚动位置不受影响。
355
+ *
356
+ * @param {{cspSource: string, nonce: string, scriptUri: string, styleUri: string}} options
108
357
  */
109
- function renderPanelHtml({ cspSource, nonce }) {
358
+ function renderPanelHtml({ cspSource, nonce, scriptUri, styleUri }) {
359
+ const script = escapeAttribute(scriptUri);
360
+ const style = escapeAttribute(styleUri);
110
361
  return `<!DOCTYPE html>
111
362
  <html lang="zh-CN">
112
363
  <head>
113
364
  <meta charset="utf-8">
114
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';">
365
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${cspSource} data:; font-src ${cspSource}; style-src ${cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';">
366
+ <link rel="stylesheet" href="${style}">
115
367
  <title>DSH 提问</title>
116
- <style>
117
- :root { color-scheme: light dark; }
118
- body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscode-font-size, 13px);
119
- color: var(--vscode-foreground); background: var(--vscode-editor-background); display: flex; flex-direction: column; height: 100vh; }
120
- header { padding: 8px 12px; border-bottom: 1px solid var(--vscode-panel-border, #8884); font-size: 12px; opacity: .85; }
121
- #log { flex: 1; overflow-y: auto; padding: 12px; }
122
- .turn { margin-bottom: 16px; }
123
- .q { background: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, #8884);
124
- border-radius: 6px; padding: 6px 8px; white-space: pre-wrap; word-break: break-word; }
125
- .a { margin-top: 8px; padding: 2px 2px 0 8px; border-left: 2px solid var(--vscode-focusBorder, #8886);
126
- white-space: pre-wrap; word-break: break-word; }
127
- .a.empty { opacity: .5; font-style: italic; }
128
- .err { color: var(--vscode-errorForeground, #f66); margin-top: 6px; white-space: pre-wrap; }
129
- footer { border-top: 1px solid var(--vscode-panel-border, #8884); padding: 8px; display: flex; gap: 8px; align-items: flex-end; }
130
- textarea { flex: 1; resize: vertical; min-height: 54px; max-height: 40vh; padding: 6px 8px; box-sizing: border-box;
131
- color: var(--vscode-input-foreground); background: var(--vscode-input-background);
132
- border: 1px solid var(--vscode-input-border, #8884); border-radius: 4px; font-family: inherit; font-size: inherit; }
133
- button { padding: 6px 14px; border: none; border-radius: 4px; cursor: pointer;
134
- color: var(--vscode-button-foreground); background: var(--vscode-button-background); }
135
- button:disabled { opacity: .5; cursor: default; }
136
- #status { padding: 0 12px 8px; font-size: 12px; opacity: .8; min-height: 16px; }
137
- </style>
138
368
  </head>
139
369
  <body>
140
- <header id="ctx">来自编辑器</header>
141
- <div id="log"></div>
142
- <div id="status"></div>
143
- <footer>
144
- <textarea id="box" placeholder="问 DSH…(Enter 发送,Shift+Enter 换行)"></textarea>
145
- <button id="send">发送</button>
146
- </footer>
147
- <script nonce="${nonce}">
148
- const vscode = acquireVsCodeApi();
149
- const log = document.getElementById('log');
150
- const ctx = document.getElementById('ctx');
151
- const status = document.getElementById('status');
152
- const box = document.getElementById('box');
153
- const send = document.getElementById('send');
154
-
155
- function render(state) {
156
- ctx.textContent = state.contextText || '来自编辑器';
157
- const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 40;
158
- log.textContent = '';
159
- for (const turn of state.turns) {
160
- const wrap = document.createElement('div');
161
- wrap.className = 'turn';
162
- const q = document.createElement('div');
163
- q.className = 'q';
164
- q.textContent = turn.question;
165
- wrap.appendChild(q);
166
- const a = document.createElement('div');
167
- a.className = 'a' + (turn.answer ? '' : ' empty');
168
- a.textContent = turn.answer || (turn.error ? '' : (turn.done ? '(没有文字回答)' : '思考中…'));
169
- wrap.appendChild(a);
170
- if (turn.error) {
171
- const err = document.createElement('div');
172
- err.className = 'err';
173
- err.textContent = turn.error;
174
- wrap.appendChild(err);
175
- }
176
- log.appendChild(wrap);
177
- }
178
- status.textContent = state.statusText || '';
179
- send.disabled = state.status === 'sending';
180
- if (atBottom) log.scrollTop = log.scrollHeight;
181
- }
182
-
183
- function submit() {
184
- const text = box.value.trim();
185
- if (text === '') return;
186
- vscode.postMessage({ type: 'ask', text });
187
- box.value = '';
188
- }
189
- send.addEventListener('click', submit);
190
- box.addEventListener('keydown', (event) => {
191
- if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); submit(); }
192
- });
193
- window.addEventListener('message', (event) => { if (event.data && event.data.type === 'state') render(event.data); });
194
- // 出错就写在状态行上:面板静默空白是最难查的一种失败。
195
- window.addEventListener('error', (event) => {
196
- status.textContent = '面板脚本出错:' + (event && event.message ? event.message : '未知');
197
- });
198
- window.addEventListener('unhandledrejection', (event) => {
199
- status.textContent = '面板脚本出错:' + (event && event.reason ? String(event.reason) : '未知');
200
- });
201
- vscode.postMessage({ type: 'ready' });
202
- box.focus();
203
- </script>
370
+ <div id="root"></div>
371
+ <script nonce="${nonce}" src="${script}"></script>
204
372
  </body>
205
373
  </html>`;
206
374
  }
207
375
 
208
- /** 状态 → 状态行文案(扩展侧组装,面板只显示)。 */
209
- function statusText(state) {
210
- switch (state.status) {
211
- case 'sending': return '发送中…';
212
- case 'thinking': return 'DSH 正在回答…';
213
- case 'error': return '出错了';
214
- default: return '';
215
- }
216
- }
217
-
218
376
  module.exports = {
219
- MAX_TURNS,
220
- MAX_MESSAGE_CHARS,
221
- escapeHtml,
377
+ MAX_ENTRIES,
378
+ MAX_PENDING,
379
+ MAX_TEXT,
380
+ DEFAULT_HOLD_MS,
381
+ normalizeText,
222
382
  createPanelState,
223
- pushQuestion,
224
- currentTurn,
225
- applyAnswers,
226
- panelPayload,
383
+ cleanEntry,
384
+ entriesSignature,
385
+ entriesOf,
386
+ applyThread,
387
+ applyApprovals,
388
+ applySync,
389
+ pendingQuestion,
390
+ failPanel,
227
391
  describeContext,
228
- renderPanelHtml,
392
+ contextLine,
229
393
  statusText,
394
+ panelPayload,
395
+ applyPayload,
396
+ createViewState,
397
+ escapeAttribute,
398
+ renderPanelHtml,
230
399
  };
@@ -277,10 +277,20 @@ function createClient(options) {
277
277
  for (const event of events) {
278
278
  if (Number.isSafeInteger(event.seq) && event.seq > since) since = event.seq;
279
279
  }
280
- // answers = 「问过 DSH 的会话」的最新回复(0.3.19)。提问框靠它就地显示回答,
281
- // 所以必须原样透传 —— 它走独立字段而不是事件环形缓冲(见 host 侧 lib/bridge-answer.mjs)。
282
- const answers = body !== null && Array.isArray(body.answers) ? body.answers : [];
283
- return { ok: true, events, answers };
280
+ // 0.2.3 起面板像 DSH 对话一样显示内容,数据走这三个字段(见 host 侧 lib/bridge-thread.mjs
281
+ // 与 lib/bridge-approval.mjs):
282
+ // - thread:被面板观看的会话的**新内容**条目(旧版宿主没有这个字段 → 面板明确报错);
283
+ // - approvals:待决授权请求(工作区外写入 / 命令执行),面板就地作答;
284
+ // - uiVersion:DSH 界面的版本(面板据此提示"渲染器与界面版本不一致")。
285
+ // 全部原样透传,解析与合并交给 lib/ask-panel.js 的纯模型。
286
+ return {
287
+ ok: true,
288
+ events,
289
+ thread: body !== null && body.thread !== undefined ? body.thread : undefined,
290
+ approvals: body !== null && Array.isArray(body.approvals) ? body.approvals : [],
291
+ approvalHoldMs: body !== null && Number.isSafeInteger(body.approvalHoldMs) ? body.approvalHoldMs : undefined,
292
+ uiVersion: body !== null && typeof body.uiVersion === 'string' ? body.uiVersion : undefined,
293
+ };
284
294
  } catch (error) {
285
295
  return { ok: false, error: error.message, status: error.status, code: error.code };
286
296
  }
@@ -293,6 +303,22 @@ function createClient(options) {
293
303
  body: JSON.stringify(payload),
294
304
  });
295
305
  },
306
+ /**
307
+ * 回答一条待决的授权请求(桥里**唯一**的非只读路由)。
308
+ *
309
+ * 约束在 host 侧写死:只认本进程发起、仍未决的 id(单次使用),outcome 只接受
310
+ * `allowed-once` / `rejected`,不接受任何自由文本 —— 这条路由只能"回答问题",不能"发起动作"。
311
+ *
312
+ * @param {string} id 授权请求 id(来自 /sync 的 approvals)
313
+ * @param {'allowed-once'|'rejected'} outcome
314
+ */
315
+ async approve(id, outcome) {
316
+ return request(`${BRIDGE_BASE}/approve`, {
317
+ method: 'POST',
318
+ headers: { 'content-type': 'application/json' },
319
+ body: JSON.stringify({ id, outcome }),
320
+ });
321
+ },
296
322
  /** 把游标落盘(实例重启后不重复播报旧事件)。 */
297
323
  persist() {
298
324
  return writeState(extensionsDir, { since, pid: config === null ? null : config.pid });
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dshcs-editor-bridge",
3
3
  "displayName": "DSH Editor Bridge",
4
- "description": "只读地把编辑器状态(未保存缓冲区、诊断、活动选区)提供给 DSH,并让编辑器里的动作驱动 DSH 会话。由 dsh-code-server-app 插件安装,可禁用。",
5
- "version": "0.2.2",
4
+ "description": "只读地把编辑器状态(未保存缓冲区、诊断、活动选区)提供给 DSH,并让编辑器里的动作驱动 DSH 会话;0.2.3 起「问 DSH」面板用 DSH 官方 markdown 渲染器显示该会话的新内容,并可就地回答授权请求(写工作区外文件 / 执行命令)。由 dsh-code-server-app 插件安装,可禁用。",
5
+ "version": "0.2.3",
6
6
  "publisher": "dsh-code-server-app",
7
7
  "license": "MIT",
8
8
  "private": true,
@@ -0,0 +1,21 @@
1
+ # 面板 webview 打包进去的第三方代码
2
+
3
+ 本目录下的 `thread.js` / `thread.css` 由 `scripts/build-webview.mjs` 打包生成,
4
+ 其中包含下列第三方包(全部 MIT 许可)。它们不是本插件的代码,原样打包只为让面板的消息正文
5
+ 与 DSH 界面完全一致(markdown 管线 / 语法高亮 / 排版令牌)。
6
+
7
+ | 包 | 版本 | 许可 |
8
+ | --- | --- | --- |
9
+ | @deepseek-ai/dsh-client-ui-primitives | 0.1.5-rc.2 | MIT |
10
+ | @deepseek-ai/dsh-client-ui-theme | 0.1.5-rc.2 | MIT |
11
+ | react | 18.3.1 | MIT |
12
+ | react-dom | 18.3.1 | MIT |
13
+
14
+ 生成时间:2026-09-13T08:28:22.125Z
15
+ 渲染器:@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.2(DSH 部署的界面版本:0.1.5-rc.2)
16
+ 设计令牌:@deepseek-ai/dsh-client-ui-theme@0.1.5-rc.2(5 段)
17
+ KaTeX:已打包(公式排版与 DSH 界面一致)
18
+ shiki 语法:typescript / shellscript / json(与 DSH 启动集一致)
19
+ 令牌自检:通过
20
+
21
+ 许可证原文见各包目录下的 LICENSE 文件(例如 `node_modules/@deepseek-ai/dsh-client-ui-primitives/LICENSE`)。