dsh-prompt-enhance 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,7 +110,8 @@ Everything lives in the `prompt-enhance` settings namespace, edited from the web
110
110
  | `maxInputChars` | `12000` | Input character cap; over-limit drafts are **rejected, never truncated** |
111
111
  | `timeoutMs` | `60000` | End-to-end deadline of one call |
112
112
  | `systemPrompt` | built-in strategy | Replace the whole enhancement strategy if you prefer your own |
113
- | `shortcut` | `ctrl+alt+e` | Global shortcut spec (modifiers + one alphanumeric/function key); empty disables it |
113
+ | `shortcut` | `ctrl+alt+e` | Global shortcut spec (at least one modifier + one alphanumeric/function key — bare keys are ignored so normal typing can never be swallowed); empty disables it |
114
+ | `provider` + `model` values | — | Match the harness settings: each key under `llm-pi-ai.providers` (e.g. `zhipu`, `muyuu`) is a provider and each `models[].id` under it (e.g. `glm-5.3-flash`) is a model. Example pair: `provider: zhipu` + `model: glm-5.3-flash` |
114
115
 
115
116
  **Model routing precedence:** explicit settings pair → the route recorded in the current session's request header → the harness-wide default model (`agent-default-model`). If none of them names a route (e.g. a fresh session with no default model), the plugin fails with an actionable message instead of guessing.
116
117
 
@@ -147,6 +148,15 @@ Set `systemPrompt` in the settings to replace all of this with your own strategy
147
148
 
148
149
  Every failure surfaces inside the plugin's own panel; the composer draft is never modified by a failed call, so manual input continues undisturbed.
149
150
 
151
+ ## Security model
152
+
153
+ The enhance route is served by your own dsh host and reachable **only from this machine**:
154
+
155
+ - **Socket fence** — requests from non-loopback addresses are refused (`127.0.0.1` / `::1` only). Note this means *any local process* can call the route; it carries no user authentication.
156
+ - **Host allowlist** — the route also validates the `Host` header against `localhost` / `127.0.0.1` / `[::1]`, which defeats DNS-rebinding (a rebound attacker domain keeps the loopback socket address but carries the attacker's hostname and is refused). Responses are `cache-control: no-store`.
157
+ - **Not for reverse-proxy exposure** — if you put dsh web behind a proxy that listens on the LAN, external callers appear as loopback to the route and the fence is moot. Do not expose a proxied host without adding your own authentication at the proxy.
158
+ - **Prompt-injection boundary** — the draft is framed between `<raw_prompt>` tags, literal closing tags inside the draft are neutralized, and the strategy prompt treats the framed text as pure data. Enhancements run with your own credentials and the result is only ever shown back to you.
159
+
150
160
  ## Architecture
151
161
 
152
162
  ```
@@ -183,12 +193,12 @@ Build outputs: `lib/index.js` (host half, ESM, package imports kept external) an
183
193
  ```bash
184
194
  npm install
185
195
  npm run typecheck # tsc --noEmit
186
- npm test # 56 unit tests (see below)
196
+ npm test # unit + real-http + component suites
187
197
  npm run build # typecheck + both halves
188
198
  npm run watch # esbuild watch for both halves
189
199
  ```
190
200
 
191
- **Test coverage:** input validation (empty / invisible characters / over-length), output normalization (fence stripping, blank-line collapsing, empty refusal), the enhancer against stubbed `ctx.llm` streams (happy path, AUTH/NO_ADAPTER/max-tokens finishes, empty output, stalled-stream timeout, pre-aborted caller, route precedence), an end-to-end real-`node:http` suite for the route (loopback fence, method guard, malformed body, structured error envelopes, disabled switch), the undo stack, shortcut parsing/matching, config resolution, and the client settings decoder.
201
+ **Test coverage:** input validation, output normalization, the enhancer against stubbed `ctx.llm` streams, a real-`node:http` route suite (loopback Host fence, disconnect abort, error envelopes), the loopback/Host fence units, prompt framing, config resolution, the undo stack, shortcut parsing, client settings, and React component tests locking the enhance → apply → undo flow (guards, stale marking, diverged-draft undo semantics).
192
202
 
193
203
  **Release process (maintainers):**
194
204
 
package/README.zh-CN.md CHANGED
@@ -110,7 +110,8 @@ dsh plugin --profile web remove dsh-prompt-enhance
110
110
  | `maxInputChars` | `12000` | 输入字数上限;超限**拒绝而不截断** |
111
111
  | `timeoutMs` | `60000` | 单次调用的端到端超时 |
112
112
  | `systemPrompt` | 内置策略 | 想用自己的增强策略时整体替换 |
113
- | `shortcut` | `ctrl+alt+e` | 快捷键规格(修饰键 + 单个字母/数字/功能键);留空禁用 |
113
+ | `shortcut` | `ctrl+alt+e` | 快捷键规格(至少一个修饰键 + 单个字母/数字/功能键——纯裸键会被忽略,绝不会吞掉正常打字);留空禁用 |
114
+ | `provider` + `model` 取值 | — | 与 harness 设置一致:设置文件 `llm-pi-ai.providers` 下的键就是 provider(如 `zhipu`、`muyuu`),其 `models[].id` 就是 model(如 `glm-5.3-flash`)。示例:`provider: zhipu` + `model: glm-5.3-flash` |
114
115
 
115
116
  **模型路由优先级:** 设置成对覆盖 → 当前会话请求头中记录的路由 → 全局默认模型(`agent-default-model`)。三者都指不出路由时(例如全新会话且无默认模型),插件给出可操作的报错而不是乱猜。
116
117
 
@@ -125,7 +126,7 @@ dsh plugin --profile web remove dsh-prompt-enhance
125
126
  5. **验收标准** —— 写明怎样判断结果正确。
126
127
  6. **边界条件** —— 列出边界情况与信息缺失时的做法。
127
128
 
128
- 硬性规则:精确保留原意(不删除、篡改、 contradict 用户信息);绝不编造——未知细节插入显式占位符如 `(待补充:…)` / `(TBD: …)`;保持任务范围不变;**只输出**改写后的正文(无解释、无围栏、无客套);镜像输入语言;长度约为原文 1–3 倍;已经成形的提示词只做轻度润色而不注水。
129
+ 硬性规则:精确保留原意(不删除、不篡改、不与用户信息冲突);绝不编造——未知细节插入显式占位符如 `(待补充:…)` / `(TBD: …)`;保持任务范围不变;**只输出**改写后的正文(无解释、无围栏、无客套);镜像输入语言;长度约为原文 1–3 倍;已经成形的提示词只做轻度润色而不注水。
129
130
 
130
131
  在设置中填写 `systemPrompt` 即可整体替换为你的策略。
131
132
 
@@ -147,6 +148,15 @@ dsh plugin --profile web remove dsh-prompt-enhance
147
148
 
148
149
  所有失败只出现在插件自己的面板里;失败的调用绝不修改输入框草稿,手动输入不受任何干扰。
149
150
 
151
+ ## 安全模型
152
+
153
+ 增强路由由你自己的 dsh 宿主提供服务,**仅限本机访问**:
154
+
155
+ - **套接字栅栏** —— 非回环地址(`127.0.0.1` / `::1` 之外)的请求一律拒绝。注意这意味着*本机任意进程*都可以调用该路由;路由本身不带用户鉴权。
156
+ - **Host 白名单** —— 路由同时校验 `Host` 头是否为 `localhost` / `127.0.0.1` / `[::1]`,可防御 DNS 重绑定(重绑定的攻击者域名虽然落在回环套接字上,但携带的是攻击者主机名,会被拒绝)。响应均为 `cache-control: no-store`。
157
+ - **不要经反向代理暴露** —— 若把 dsh web 放在监听局域网的代理后面,外部调用者在路由看来就是回环地址,栅栏形同虚设。除非在代理层自行加鉴权,否则不要暴露。
158
+ - **提示注入边界** —— 草稿被框在 `<raw_prompt>` 标签之间,草稿内的字面闭合标签会被中和,策略提示词把框内文本视为纯数据。增强只使用你自己的凭据,结果也只回显给你本人。
159
+
150
160
  ## 架构
151
161
 
152
162
  ```
@@ -182,7 +192,7 @@ src/
182
192
  ```bash
183
193
  npm install
184
194
  npm run typecheck # tsc --noEmit
185
- npm test # 56 个单元测试(见下)
195
+ npm test # 单元 + 真实 HTTP + 组件测试套件
186
196
  npm run build # 类型检查 + 双半区构建
187
197
  npm run watch # esbuild 监听构建双半区
188
198
  ```
package/lib/client.js CHANGED
@@ -100,19 +100,56 @@ async function requestEnhance(body, signal) {
100
100
  var import_react = require("react");
101
101
  var import_jsx_runtime = require("react/jsx-runtime");
102
102
  var retryable = (code) => code === "upstream" || code === "timeout" || code === "internal";
103
+ function localizedErrorMessage(t, code) {
104
+ switch (code) {
105
+ case "rejected":
106
+ return t("error.rejected");
107
+ case "timeout":
108
+ return t("error.timeout");
109
+ case "unconfigured":
110
+ return t("error.unconfigured");
111
+ case "upstream":
112
+ return t("error.upstream");
113
+ default:
114
+ return t("error.internal");
115
+ }
116
+ }
103
117
  function ResultPanel(props) {
104
118
  const { state, t, onApply, onCancel, onRetry } = props;
105
119
  const [copied, setCopied] = (0, import_react.useState)("idle");
120
+ const panelRef = (0, import_react.useRef)(null);
106
121
  (0, import_react.useEffect)(() => {
107
122
  const onKeyDown = (event) => {
123
+ if (event.isComposing) return;
108
124
  if (event.key === "Escape" && !event.defaultPrevented) {
109
125
  event.preventDefault();
110
126
  onCancel();
127
+ return;
128
+ }
129
+ if (event.key === "Tab") {
130
+ const panel = panelRef.current;
131
+ if (panel === null) return;
132
+ const focusables = panel.querySelectorAll('button:not([disabled]), [href], input, [tabindex]:not([tabindex="-1"])');
133
+ const first = focusables[0];
134
+ const last = focusables[focusables.length - 1];
135
+ if (first === void 0 || last === void 0) return;
136
+ if (event.shiftKey && document.activeElement === first) {
137
+ event.preventDefault();
138
+ last.focus();
139
+ } else if (!event.shiftKey && document.activeElement === last) {
140
+ event.preventDefault();
141
+ first.focus();
142
+ }
111
143
  }
112
144
  };
113
145
  document.addEventListener("keydown", onKeyDown);
114
146
  return () => document.removeEventListener("keydown", onKeyDown);
115
147
  }, [onCancel]);
148
+ (0, import_react.useEffect)(() => {
149
+ const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
150
+ panelRef.current?.focus();
151
+ return () => previous?.focus();
152
+ }, []);
116
153
  const copy = (0, import_react.useCallback)(() => {
117
154
  if (state.result === void 0) return;
118
155
  navigator.clipboard.writeText(state.result.text).then(() => setCopied("ok"), () => setCopied("failed"));
@@ -120,45 +157,63 @@ function ResultPanel(props) {
120
157
  const formatMs = (ms) => ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
121
158
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-pe-overlay", onMouseDown: (event) => {
122
159
  if (event.target === event.currentTarget) onCancel();
123
- }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "dsh-pe-panel", role: "dialog", "aria-label": t("panel.title"), children: [
124
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("header", { className: "dsh-pe-head", children: [
125
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
126
- "\u2728 ",
127
- t("panel.title")
128
- ] }),
129
- state.phase === "result" && state.result !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "dsh-pe-meta", children: [
130
- t("panel.model", { provider: state.result.provider, model: state.result.model }),
131
- " \xB7 ",
132
- t("panel.elapsed", { ms: formatMs(state.result.elapsedMs) })
133
- ] }),
134
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dsh-pe-close", "aria-label": t("panel.close"), onClick: onCancel, children: "\u2715" })
135
- ] }),
136
- state.phase === "loading" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-pe-loading", children: [
137
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-pe-spin", "aria-hidden": true }),
138
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { children: t("panel.loading") }),
139
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-pe-hint", children: t("panel.loading.hint") })
140
- ] }),
141
- state.phase === "error" && state.error !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-pe-error", children: state.error.message }),
142
- state.phase === "result" && state.result !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-pe-body", children: [
143
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-pe-col", children: [
144
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-pe-col-title", children: t("panel.original") }),
145
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("pre", { className: "dsh-pe-col-text", children: state.original })
146
- ] }),
147
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-pe-col", children: [
148
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-pe-col-title", children: t("panel.enhanced") }),
149
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("pre", { className: "dsh-pe-col-text", children: state.result.text })
150
- ] })
151
- ] }),
152
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("footer", { className: "dsh-pe-foot", children: [
153
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-pe-grow" }),
154
- state.phase === "error" && retryable(state.error?.code) && onRetry !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dsh-pe-btn-action plain", onClick: onRetry, children: t("panel.retry") }),
155
- state.phase === "result" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
156
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dsh-pe-btn-action plain", onClick: copy, children: copied === "ok" ? t("panel.copied") : copied === "failed" ? t("panel.copyFailed") : t("panel.copy") }),
157
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dsh-pe-btn-action primary", onClick: onApply, children: t("panel.apply") })
158
- ] }),
159
- state.phase !== "result" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dsh-pe-btn-action plain", onClick: onCancel, children: state.phase === "loading" ? t("panel.cancel") : t("panel.close") })
160
- ] })
161
- ] }) });
160
+ }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
161
+ "section",
162
+ {
163
+ ref: panelRef,
164
+ className: "dsh-pe-panel",
165
+ role: "dialog",
166
+ "aria-modal": "true",
167
+ "aria-label": t("panel.title"),
168
+ tabIndex: -1,
169
+ children: [
170
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("header", { className: "dsh-pe-head", children: [
171
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
172
+ "\u2728 ",
173
+ t("panel.title")
174
+ ] }),
175
+ state.phase === "result" && state.result !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "dsh-pe-meta", children: [
176
+ t("panel.model", { provider: state.result.provider, model: state.result.model }),
177
+ " \xB7 ",
178
+ t("panel.elapsed", { ms: formatMs(state.result.elapsedMs) })
179
+ ] }),
180
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dsh-pe-close", "aria-label": t("panel.close"), onClick: onCancel, children: "\u2715" })
181
+ ] }),
182
+ state.phase === "loading" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-pe-loading", children: [
183
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-pe-spin", "aria-hidden": true }),
184
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { children: t("panel.loading") }),
185
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-pe-hint", children: t("panel.loading.hint") })
186
+ ] }),
187
+ state.phase === "error" && state.error !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-pe-error", children: [
188
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { children: state.error.localized ?? localizedErrorMessage(t, state.error.code) }),
189
+ state.error.localized !== state.error.message && state.error.message !== "" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-pe-error-detail", children: state.error.message })
190
+ ] }),
191
+ state.phase === "result" && state.result !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-pe-body", children: [
192
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-pe-col", children: [
193
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-pe-col-title", children: t("panel.original") }),
194
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("pre", { className: "dsh-pe-col-text", children: state.original })
195
+ ] }),
196
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-pe-col", children: [
197
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-pe-col-title", children: t("panel.enhanced") }),
198
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("pre", { className: "dsh-pe-col-text", children: state.result.text })
199
+ ] })
200
+ ] }),
201
+ state.phase === "result" && state.stale && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-pe-stale", children: [
202
+ "\u26A0 ",
203
+ t("panel.stale.warn")
204
+ ] }),
205
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("footer", { className: "dsh-pe-foot", children: [
206
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-pe-grow" }),
207
+ state.phase === "error" && retryable(state.error?.code) && onRetry !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dsh-pe-btn-action plain", onClick: onRetry, children: t("panel.retry") }),
208
+ state.phase === "result" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
209
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dsh-pe-btn-action plain", onClick: copy, children: copied === "ok" ? t("panel.copied") : copied === "failed" ? t("panel.copyFailed") : t("panel.copy") }),
210
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dsh-pe-btn-action primary", onClick: onApply, children: t("panel.apply") })
211
+ ] }),
212
+ state.phase !== "result" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dsh-pe-btn-action plain", onClick: onCancel, children: state.phase === "loading" ? t("panel.cancel") : t("panel.close") })
213
+ ] })
214
+ ]
215
+ }
216
+ ) });
162
217
  }
163
218
 
164
219
  // src/client/undo-stack.ts
@@ -244,6 +299,11 @@ function openError(sessionId, original, error) {
244
299
  panelState = { sessionId, phase: "error", original, error };
245
300
  notify();
246
301
  }
302
+ function setStale(sessionId, stale) {
303
+ if (panelState?.sessionId !== sessionId || panelState.phase !== "result" || panelState.stale === stale) return;
304
+ panelState = { ...panelState, stale };
305
+ notify();
306
+ }
247
307
  function closePanel() {
248
308
  panelState?.abort?.();
249
309
  if (panelState === void 0) return;
@@ -256,7 +316,10 @@ function registerSession(sessionId, entry) {
256
316
  notify();
257
317
  return () => {
258
318
  if (sessions.get(sessionId) === entry) sessions.delete(sessionId);
259
- if (lastMountedSession === sessionId) lastMountedSession = void 0;
319
+ if (lastMountedSession === sessionId) {
320
+ const keys = [...sessions.keys()];
321
+ lastMountedSession = keys[keys.length - 1];
322
+ }
260
323
  if (panelState?.sessionId === sessionId) closePanel();
261
324
  undoStore.clear(sessionId);
262
325
  };
@@ -324,30 +387,27 @@ function EnhanceButton(props) {
324
387
  const busy = panel !== void 0 && panel.sessionId === sessionId && panel.phase === "loading";
325
388
  const anyBusy = panel !== void 0 && panel.phase === "loading";
326
389
  const start = (0, import_react2.useCallback)(() => {
327
- if (anyBusy) {
328
- openError(sessionId, draft, { code: "rejected", message: t("error.busy") });
329
- return;
330
- }
390
+ if (anyBusy) return;
331
391
  if (!settings.enabled) {
332
- openError(sessionId, draft, { code: "rejected", message: t("error.disabled") });
392
+ openError(sessionId, draft, { code: "rejected", message: t("error.disabled"), localized: t("error.disabled") });
333
393
  return;
334
394
  }
335
395
  if (imageCount > 0 && draft.trim() === "") {
336
- openError(sessionId, draft, { code: "rejected", message: t("error.imagesOnly") });
396
+ openError(sessionId, draft, { code: "rejected", message: t("error.imagesOnly"), localized: t("error.imagesOnly") });
337
397
  return;
338
398
  }
339
399
  const check = checkInputText(draft, settings.maxInputChars);
340
400
  if (!check.ok) {
341
401
  const message = check.code === "empty" ? t("error.empty") : t("error.tooLong", { count: check.count, max: check.max });
342
- openError(sessionId, draft, { code: "rejected", message });
402
+ openError(sessionId, draft, { code: "rejected", message, localized: message });
343
403
  return;
344
404
  }
345
405
  if (occurrenceCount > 0) {
346
- openError(sessionId, draft, { code: "rejected", message: t("error.occurrences") });
406
+ openError(sessionId, draft, { code: "rejected", message: t("error.occurrences"), localized: t("error.occurrences") });
347
407
  return;
348
408
  }
349
409
  if (phase !== "plain") {
350
- openError(sessionId, draft, { code: "rejected", message: t("error.phase") });
410
+ openError(sessionId, draft, { code: "rejected", message: t("error.phase"), localized: t("error.phase") });
351
411
  return;
352
412
  }
353
413
  const controller = new AbortController();
@@ -369,12 +429,17 @@ function EnhanceButton(props) {
369
429
  };
370
430
  return registerSession(sessionId, entry);
371
431
  }, [sessionId]);
432
+ (0, import_react2.useEffect)(() => {
433
+ if (panel !== void 0 && panel.sessionId === sessionId && panel.phase === "result") {
434
+ setStale(sessionId, draft !== panel.original);
435
+ }
436
+ }, [draft, panel, sessionId]);
372
437
  const apply2 = (0, import_react2.useCallback)(() => {
373
438
  if (panel === void 0 || panel.phase !== "result" || panel.result === void 0) return;
374
- pushUndo(sessionId, { original: panel.original, applied: panel.result.text });
439
+ pushUndo(sessionId, { original: draft, applied: panel.result.text });
375
440
  inputActions.setDraft(panel.result.text);
376
441
  closePanel();
377
- }, [inputActions, panel, sessionId]);
442
+ }, [draft, inputActions, panel, sessionId]);
378
443
  if (!settings.enabled) return null;
379
444
  const owned = panel !== void 0 && panel.sessionId === sessionId ? panel : void 0;
380
445
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
@@ -451,6 +516,7 @@ var zh = {
451
516
  "panel.copyFailed": "\u590D\u5236\u5931\u8D25",
452
517
  "panel.close": "\u5173\u95ED",
453
518
  "panel.retry": "\u91CD\u8BD5",
519
+ "panel.stale.warn": "\u8349\u7A3F\u5728\u589E\u5F3A\u671F\u95F4\u6709\u6539\u52A8\u2014\u2014\u589E\u5F3A\u7ED3\u679C\u57FA\u4E8E\u589E\u5F3A\u524D\u7684\u6587\u672C\u3002\u56DE\u586B\u5C06\u8986\u76D6\u4F60\u7684\u6700\u65B0\u7F16\u8F91(\u64A4\u9500\u53EF\u6062\u590D\u56DE\u586B\u524D\u7684\u8349\u7A3F)\u3002",
454
520
  "panel.model": "\u6A21\u578B\uFF1A{provider} / {model}",
455
521
  "panel.elapsed": "\u8017\u65F6 {ms}",
456
522
  "undo.applied": "\u5DF2\u7528\u589E\u5F3A\u7ED3\u679C\u66FF\u6362\u539F\u63D0\u793A\u8BCD",
@@ -460,9 +526,13 @@ var zh = {
460
526
  "error.tooLong": "\u5185\u5BB9\u5171 {count} \u5B57\uFF0C\u8D85\u8FC7 {max} \u5B57\u4E0A\u9650\u3002\u4E3A\u907F\u514D\u6539\u53D8\u539F\u610F\u4E0D\u4F1A\u81EA\u52A8\u622A\u65AD\uFF0C\u8BF7\u7CBE\u7B80\u540E\u518D\u8BD5\u3002",
461
527
  "error.imagesOnly": "\u5F53\u524D\u53EA\u9644\u52A0\u4E86\u56FE\u7247\uFF0C\u4EC5\u652F\u6301\u589E\u5F3A\u6587\u672C\u5185\u5BB9\u3002",
462
528
  "error.occurrences": "\u8F93\u5165\u5185\u5BB9\u5305\u542B\u547D\u4EE4\u6216\u6587\u4EF6\u5F15\u7528\uFF0C\u6682\u4E0D\u652F\u6301\u589E\u5F3A\uFF1B\u8BF7\u5148\u79FB\u9664\u540E\u518D\u8BD5\u3002",
463
- "error.busy": "\u6B63\u5728\u589E\u5F3A\u4E2D\uFF0C\u8BF7\u7A0D\u5019\u6216\u5148\u53D6\u6D88\u3002",
464
529
  "error.phase": "\u5F53\u524D\u8F93\u5165\u6B63\u88AB\u5360\u7528\uFF08\u63D0\u4EA4\u4E2D\uFF09\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002",
465
- "error.disabled": "\u63D0\u793A\u8BCD\u589E\u5F3A\u5DF2\u5728\u63D2\u4EF6\u8BBE\u7F6E\u4E2D\u5173\u95ED\u3002"
530
+ "error.disabled": "\u63D0\u793A\u8BCD\u589E\u5F3A\u5DF2\u5728\u63D2\u4EF6\u8BBE\u7F6E\u4E2D\u5173\u95ED\u3002",
531
+ "error.rejected": "\u589E\u5F3A\u8BF7\u6C42\u88AB\u62D2\u7EDD\u3002",
532
+ "error.timeout": "\u589E\u5F3A\u8D85\u65F6\uFF0C\u8BF7\u91CD\u8BD5\uFF1B\u539F\u8F93\u5165\u672A\u6539\u52A8\u3002",
533
+ "error.unconfigured": "\u5C1A\u672A\u786E\u5B9A\u589E\u5F3A\u7528\u7684\u6A21\u578B\uFF1A\u8BF7\u5728\u63D2\u4EF6\u8BBE\u7F6E\u4E2D\u6210\u5BF9\u586B\u5199 provider/model\u3002",
534
+ "error.upstream": "\u6A21\u578B\u670D\u52A1\u8FD4\u56DE\u9519\u8BEF\uFF0C\u8BF7\u91CD\u8BD5\uFF1B\u539F\u8F93\u5165\u672A\u6539\u52A8\u3002",
535
+ "error.internal": "\u589E\u5F3A\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\uFF1B\u539F\u8F93\u5165\u672A\u6539\u52A8\u3002"
466
536
  };
467
537
  var en = {
468
538
  "button.title": "Enhance prompt (rewrite into a structured prompt)",
@@ -479,6 +549,7 @@ var en = {
479
549
  "panel.copyFailed": "Copy failed",
480
550
  "panel.close": "Close",
481
551
  "panel.retry": "Retry",
552
+ "panel.stale.warn": "The draft changed while enhancing \u2014 the result is based on the pre-enhance text. Applying will overwrite your latest edits (undo restores the draft as it was before applying).",
482
553
  "panel.model": "Model: {provider} / {model}",
483
554
  "panel.elapsed": "{ms} elapsed",
484
555
  "undo.applied": "Original prompt replaced by the enhanced version",
@@ -488,9 +559,13 @@ var en = {
488
559
  "error.tooLong": "The draft is {count} characters, above the {max} cap. It is never auto-truncated (that would change your meaning) \u2014 please shorten it.",
489
560
  "error.imagesOnly": "Only images are attached; prompt enhance supports text only.",
490
561
  "error.occurrences": "The draft contains commands or file references, which are not supported yet \u2014 remove them first.",
491
- "error.busy": "An enhancement is already running \u2014 wait or cancel it first.",
492
562
  "error.phase": "The input box is busy (submitting) \u2014 try again in a moment.",
493
- "error.disabled": "Prompt enhance is disabled in the plugin settings."
563
+ "error.disabled": "Prompt enhance is disabled in the plugin settings.",
564
+ "error.rejected": "The enhance request was rejected.",
565
+ "error.timeout": "The enhancement timed out. Retry; your draft is untouched.",
566
+ "error.unconfigured": "No model resolved for the enhancement: pair provider/model in the plugin settings.",
567
+ "error.upstream": "The model provider returned an error. Retry; your draft is untouched.",
568
+ "error.internal": "Enhancement failed. Retry; your draft is untouched."
494
569
  };
495
570
  var dictionaries = { zh, en };
496
571
  var NS = "prompt-enhance";
@@ -498,32 +573,10 @@ var NS = "prompt-enhance";
498
573
  // src/client/styles.ts
499
574
  var STYLE_ID = "dsh-prompt-enhance-styles";
500
575
  var CSS = `
501
- .dsh-pe-btn {
502
- display: inline-flex;
503
- align-items: center;
504
- gap: 4px;
505
- height: 28px;
506
- padding: 0 8px;
507
- border: none;
508
- border-radius: 6px;
509
- background: transparent;
510
- color: inherit;
511
- opacity: 0.75;
512
- font-size: 12px;
513
- line-height: 1;
514
- cursor: pointer;
515
- white-space: nowrap;
516
- }
517
- .dsh-pe-btn:hover:not(:disabled) { opacity: 1; background: rgba(127, 127, 127, 0.18); }
518
- .dsh-pe-btn:disabled { opacity: 0.45; cursor: default; }
519
- .dsh-pe-btn .dsh-pe-btn-icon { font-size: 14px; }
520
- .dsh-pe-btn.is-busy .dsh-pe-btn-icon { animation: dsh-pe-spin 1s linear infinite; }
521
- @keyframes dsh-pe-spin { to { transform: rotate(360deg); } }
522
-
523
576
  .dsh-pe-overlay {
524
577
  position: fixed;
525
578
  inset: 0;
526
- z-index: 1000;
579
+ z-index: var(--dsh-pe-z-index, 1000);
527
580
  display: flex;
528
581
  align-items: flex-end;
529
582
  justify-content: center;
@@ -531,23 +584,48 @@ var CSS = `
531
584
  padding: 24px 16px;
532
585
  }
533
586
  .dsh-pe-panel {
587
+ --pe-bg: #1f2127;
588
+ --pe-fg: #e6e8ee;
589
+ --pe-fg-dim: rgba(230, 232, 238, 0.55);
590
+ --pe-border: rgba(127, 127, 127, 0.35);
591
+ --pe-separator: rgba(127, 127, 127, 0.25);
592
+ --pe-hover: rgba(127, 127, 127, 0.18);
593
+ --pe-danger: #ffb4a8;
594
+ --pe-warn-fg: #e8c07d;
595
+ --pe-warn-border: rgba(230, 158, 60, 0.35);
596
+ --pe-warn-bg: rgba(230, 158, 60, 0.12);
534
597
  display: flex;
535
598
  flex-direction: column;
536
599
  width: min(880px, 100%);
537
600
  max-height: min(70vh, 640px);
538
601
  border-radius: 12px;
539
- border: 1px solid rgba(127, 127, 127, 0.35);
540
- background: #1f2127;
541
- color: #e6e8ee;
602
+ border: 1px solid var(--pe-border);
603
+ background: var(--pe-bg);
604
+ color: var(--pe-fg);
542
605
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
543
606
  overflow: hidden;
607
+ outline: none;
608
+ }
609
+ @media (prefers-color-scheme: light) {
610
+ .dsh-pe-panel {
611
+ --pe-bg: #ffffff;
612
+ --pe-fg: #1c1e24;
613
+ --pe-fg-dim: rgba(28, 30, 36, 0.55);
614
+ --pe-border: rgba(0, 0, 0, 0.18);
615
+ --pe-separator: rgba(0, 0, 0, 0.12);
616
+ --pe-hover: rgba(0, 0, 0, 0.08);
617
+ --pe-danger: #a63a2e;
618
+ --pe-warn-fg: #8a5a00;
619
+ --pe-warn-border: rgba(176, 122, 0, 0.4);
620
+ --pe-warn-bg: rgba(230, 158, 60, 0.15);
621
+ }
544
622
  }
545
623
  .dsh-pe-head {
546
624
  display: flex;
547
625
  align-items: center;
548
626
  gap: 8px;
549
627
  padding: 10px 14px;
550
- border-bottom: 1px solid rgba(127, 127, 127, 0.25);
628
+ border-bottom: 1px solid var(--pe-separator);
551
629
  font-size: 13px;
552
630
  font-weight: 600;
553
631
  }
@@ -556,7 +634,7 @@ var CSS = `
556
634
  border: none; background: transparent; color: inherit; opacity: 0.6;
557
635
  font-size: 15px; cursor: pointer; padding: 2px 6px; border-radius: 4px;
558
636
  }
559
- .dsh-pe-close:hover { opacity: 1; background: rgba(127,127,127,0.2); }
637
+ .dsh-pe-close:hover { opacity: 1; background: var(--pe-hover); }
560
638
  .dsh-pe-body { display: flex; flex: 1; min-height: 0; }
561
639
  .dsh-pe-col {
562
640
  flex: 1 1 0;
@@ -564,7 +642,7 @@ var CSS = `
564
642
  display: flex;
565
643
  flex-direction: column;
566
644
  }
567
- .dsh-pe-col + .dsh-pe-col { border-left: 1px solid rgba(127, 127, 127, 0.25); }
645
+ .dsh-pe-col + .dsh-pe-col { border-left: 1px solid var(--pe-separator); }
568
646
  .dsh-pe-col-title {
569
647
  padding: 8px 14px 6px;
570
648
  font-size: 11px;
@@ -586,9 +664,15 @@ var CSS = `
586
664
  padding: 20px 16px;
587
665
  font-size: 13px;
588
666
  line-height: 1.6;
589
- color: #ffb4a8;
667
+ color: var(--pe-danger);
590
668
  white-space: pre-wrap;
591
669
  }
670
+ .dsh-pe-error .dsh-pe-error-detail {
671
+ margin-top: 8px;
672
+ font-size: 12px;
673
+ opacity: 0.75;
674
+ color: inherit;
675
+ }
592
676
  .dsh-pe-loading {
593
677
  display: flex; flex-direction: column; gap: 8px;
594
678
  align-items: center; justify-content: center;
@@ -597,7 +681,7 @@ var CSS = `
597
681
  .dsh-pe-loading .dsh-pe-hint { font-size: 11px; opacity: 0.55; }
598
682
  .dsh-pe-spin {
599
683
  width: 22px; height: 22px; border-radius: 50%;
600
- border: 2px solid rgba(127,127,127,0.3); border-top-color: #6ea8ff;
684
+ border: 2px solid var(--pe-separator); border-top-color: #6ea8ff;
601
685
  animation: dsh-pe-spin 0.9s linear infinite;
602
686
  }
603
687
  .dsh-pe-foot {
@@ -605,7 +689,7 @@ var CSS = `
605
689
  align-items: center;
606
690
  gap: 8px;
607
691
  padding: 10px 14px;
608
- border-top: 1px solid rgba(127, 127, 127, 0.25);
692
+ border-top: 1px solid var(--pe-separator);
609
693
  }
610
694
  .dsh-pe-foot .dsh-pe-grow { flex: 1; }
611
695
  .dsh-pe-btn-action {
@@ -614,8 +698,39 @@ var CSS = `
614
698
  }
615
699
  .dsh-pe-btn-action.primary { background: #3f76e1; color: #fff; }
616
700
  .dsh-pe-btn-action.primary:hover { background: #4d82ec; }
617
- .dsh-pe-btn-action.plain { background: rgba(127,127,127,0.18); color: inherit; }
618
- .dsh-pe-btn-action.plain:hover { background: rgba(127,127,127,0.3); }
701
+ .dsh-pe-btn-action.plain { background: var(--pe-hover); color: inherit; }
702
+ .dsh-pe-btn-action.plain:hover { background: rgba(127, 127, 127, 0.3); }
703
+
704
+ .dsh-pe-stale {
705
+ padding: 8px 14px;
706
+ font-size: 12px;
707
+ line-height: 1.5;
708
+ border-top: 1px solid var(--pe-warn-border);
709
+ background: var(--pe-warn-bg);
710
+ color: var(--pe-warn-fg);
711
+ }
712
+
713
+ .dsh-pe-btn {
714
+ display: inline-flex;
715
+ align-items: center;
716
+ gap: 4px;
717
+ height: 28px;
718
+ padding: 0 8px;
719
+ border: none;
720
+ border-radius: 6px;
721
+ background: transparent;
722
+ color: inherit;
723
+ opacity: 0.75;
724
+ font-size: 12px;
725
+ line-height: 1;
726
+ cursor: pointer;
727
+ white-space: nowrap;
728
+ }
729
+ .dsh-pe-btn:hover:not(:disabled) { opacity: 1; background: var(--pe-hover); }
730
+ .dsh-pe-btn:disabled { opacity: 0.45; cursor: default; }
731
+ .dsh-pe-btn .dsh-pe-btn-icon { font-size: 14px; }
732
+ .dsh-pe-btn.is-busy .dsh-pe-btn-icon { animation: dsh-pe-spin 1s linear infinite; }
733
+ @keyframes dsh-pe-spin { to { transform: rotate(360deg); } }
619
734
 
620
735
  .dsh-pe-undo {
621
736
  display: flex;
@@ -628,7 +743,7 @@ var CSS = `
628
743
  }
629
744
  .dsh-pe-undo .dsh-pe-undo-check { color: #7ed491; }
630
745
  .dsh-pe-undo-link {
631
- border: none; background: rgba(127,127,127,0.18); color: inherit;
746
+ border: none; background: var(--pe-hover); color: inherit;
632
747
  border-radius: 5px; padding: 3px 10px; font-size: 12px; cursor: pointer;
633
748
  }
634
749
  .dsh-pe-undo-link:hover { background: rgba(127,127,127,0.3); }
@@ -673,6 +788,7 @@ function parseShortcut(spec) {
673
788
  if (modifier === void 0) return null;
674
789
  modifiers[modifier] = true;
675
790
  }
791
+ if (!modifiers.ctrl && !modifiers.alt && !modifiers.shift && !modifiers.meta) return null;
676
792
  return { ...modifiers, key: last };
677
793
  }
678
794
  function matchesShortcut(event, combo) {