dsh-codinput 0.3.8 → 0.4.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/NOTICE +37 -4
  3. package/README.en.md +28 -13
  4. package/README.md +28 -13
  5. package/TESTING.md +96 -9
  6. package/assets/donate-qr.jpg +0 -0
  7. package/lib/client.js +2875 -98
  8. package/package.json +5 -2
  9. package/src/client/cm.ts +19 -1
  10. package/src/client/command.ts +39 -8
  11. package/src/client/components/CodinputSurface.tsx +80 -27
  12. package/src/client/components/EditorPane.tsx +3 -1
  13. package/src/client/components/InputSlotSeat.tsx +56 -1
  14. package/src/client/components/MenuSurface.tsx +64 -12
  15. package/src/client/components/ModelMenu.tsx +5 -17
  16. package/src/client/components/PermissionMenu.tsx +8 -3
  17. package/src/client/components/SlashMenu.tsx +294 -41
  18. package/src/client/components/StatsRow.tsx +40 -23
  19. package/src/client/components/Toolbar.tsx +284 -48
  20. package/src/client/components/UserMessageMarkdown.tsx +158 -115
  21. package/src/client/components/VoiceActivity.tsx +30 -69
  22. package/src/client/components/slotEntry.tsx +37 -0
  23. package/src/client/host-types.ts +11 -3
  24. package/src/client/hotkey.ts +38 -0
  25. package/src/client/i18n.ts +18 -4
  26. package/src/client/index.tsx +9 -0
  27. package/src/client/markdown/code.ts +281 -0
  28. package/src/client/markdown/extensions.ts +576 -0
  29. package/src/client/markdown/heavy-assets.d.ts +11 -0
  30. package/src/client/markdown/heavy-shared.ts +50 -0
  31. package/src/client/markdown/heavy.ts +159 -0
  32. package/src/client/markdown/index.ts +289 -0
  33. package/src/client/markdown/math.ts +134 -0
  34. package/src/client/markdown/mermaid.ts +358 -0
  35. package/src/client/markdown/sanitize.ts +103 -0
  36. package/src/client/markdown/styles.ts +171 -0
  37. package/src/client/prefs.ts +206 -23
  38. package/src/client/settings.tsx +15 -3
  39. package/src/client/stats.ts +92 -20
  40. package/src/client/styles.ts +55 -30
@@ -1,5 +1,7 @@
1
1
  /** 偏好存储:localStorage 持久化(技术决策:无宿主往返,升级宿主不影响)。 */
2
2
 
3
+ import { isValidHotkey, normalizeHotkey } from './hotkey';
4
+
3
5
  /** 快捷键组合:规范化串(如 "mod+enter"、"ctrl+shift+k"),见 hotkey.ts。 */
4
6
  export type Hotkey = string;
5
7
 
@@ -21,6 +23,13 @@ export interface CodinputPrefs {
21
23
  userMarkdown: boolean;
22
24
  /** 显示行号(当前行与行号高亮常开)。 */
23
25
  lineNumbers: boolean;
26
+ /**
27
+ * Markdown 正文等宽连字字体(方案 A):预览与用户消息的正文改用
28
+ * 'JetBrains Mono' 等宽连字字体,`->` / `=>` / `//` 等靠字体 calt/liga 连字
29
+ * 显示成箭头/连字(DOM 文本仍是原文,不做字符替换)。只影响 Markdown 显示面;
30
+ * 编辑器输入框保持字面(桌面 Chromium 152 的 CM 连字 shaping bug)。默认开。
31
+ */
32
+ markdownMono: boolean;
24
33
  /** 编辑器字体(默认 JetBrains Mono)。 */
25
34
  fontFamily: string;
26
35
  /** 发送快捷键(任意组合,默认 Cmd/Ctrl+Enter)。 */
@@ -47,6 +56,7 @@ export const DEFAULT_PREFS: CodinputPrefs = {
47
56
  enabled: true,
48
57
  userMarkdown: true,
49
58
  lineNumbers: true,
59
+ markdownMono: true,
50
60
  fontFamily: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
51
61
  sendHotkey: 'mod+enter',
52
62
  lineBreakHotkey: 'enter',
@@ -62,43 +72,216 @@ export const DEFAULT_PREFS: CodinputPrefs = {
62
72
 
63
73
  // v2:旧版(已移除的 v0.1.0)遗留的 'dsh-codinput.prefs' 形状不同且可能
64
74
  // enabled:false——换键避免被旧数据劫持接管开关。
65
- const KEY = 'dsh-codinput.prefs.v2';
75
+ // v3:v2 清理 + 字段归一化迁移(B1/B2:坏 `shift-enter` 修复、旧字段删除)。
76
+ // 此前曾把 `fontLigatures` 默认改为 true 并在 v3 里持久化;后续
77
+ // **移除了字体连字功能**(桌面 Chromium 152 的 calt shaping bug 无法稳定规避),
78
+ // 该字段从 v3 起被忽略、下次保存时自动丢弃;此处保留 v3 键名避免无谓地
79
+ // 重放迁移。
80
+ const KEY = 'dsh-codinput.prefs.v3';
81
+ const LEGACY_KEY = 'dsh-codinput.prefs.v2';
66
82
  const PERM_KEY = 'dsh-codinput.permission-applied';
67
83
 
84
+ /**
85
+ * 已废弃的旧字段:迁移时只读一次,随后从存储对象删除(B2)。留着它们
86
+ * 会让每次加载都重新迁移,把用户后来改过的快捷键覆盖回旧值。
87
+ * - `sendKey`:两态快捷键时代的发送键('enter' | 'mod-enter');
88
+ * - `lineBreakKey`:同代换行键(当前无迁移值,仅随旧对象删除);
89
+ * - `floating`:两态入口时代的悬浮标记(true → mode:'float')。
90
+ */
91
+ const LEGACY_KEYS = ['sendKey', 'lineBreakKey', 'floating'] as const;
92
+
93
+ /** 数值字段允许区间:与 UI 的拖拽/缩放夹取一致,兼作脏数据兜底。 */
94
+ const BODY_HEIGHT_MIN = 96;
95
+ const BODY_HEIGHT_MAX = 4000;
96
+ const FLOAT_W_MIN = 360;
97
+ const FLOAT_H_MIN = 140;
98
+ const FLOAT_SIZE_MAX = 10000;
99
+ const FLOAT_POS_MAX = 100000;
100
+ const FONT_FAMILY_MAX = 256;
101
+
68
102
  const listeners = new Set<() => void>();
69
103
 
104
+ function isRecord(value: unknown): value is Record<string, unknown> {
105
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
106
+ }
107
+
108
+ /** 布尔字段:缺失/非布尔回退默认。 */
109
+ function readBoolean(value: unknown, fallback: boolean): boolean {
110
+ return typeof value === 'boolean' ? value : fallback;
111
+ }
112
+
113
+ /** 旧版组合串分隔符 `-` → 规范 `+`(B1:'shift-enter' → 'shift+enter')。 */
114
+ function repairHotkey(value: unknown): unknown {
115
+ if (typeof value !== 'string') return value;
116
+ const combo = value.trim();
117
+ return /^[a-z0-9]+(?:-[a-z0-9]+)+$/i.test(combo) ? combo.replace(/-/g, '+') : combo;
118
+ }
119
+
120
+ /** 快捷键字段:修复旧分隔符 → basic 校验 → 规范化;不合法回退默认。 */
121
+ function readHotkey(value: unknown, fallback: Hotkey): Hotkey {
122
+ const repaired = repairHotkey(value);
123
+ return isValidHotkey(repaired) ? normalizeHotkey(repaired) : fallback;
124
+ }
125
+
126
+ /** 正数区间字段:非有限数/非正数、越界 → 夹取或 null。 */
127
+ function readPositive(value: unknown, min: number, max: number): number | null {
128
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null;
129
+ const rounded = Math.round(value);
130
+ if (rounded <= 0) return null;
131
+ return Math.min(max, Math.max(min, rounded));
132
+ }
133
+
134
+ /** 视口坐标字段:非有限数/负数 → null;其余夹取上限。 */
135
+ function readPosition(value: unknown): number | null {
136
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null;
137
+ const rounded = Math.round(value);
138
+ if (rounded < 0) return null;
139
+ return Math.min(FLOAT_POS_MAX, rounded);
140
+ }
141
+
142
+ /**
143
+ * 任意来源(localStorage / 调用方 patch)→ 合法 CodinputPrefs(B3):
144
+ * 每个字段独立做类型/枚举/范围校验,缺失或脏值逐项回退默认;未知字段丢弃。
145
+ * 任意输入都不抛错;同时是「存储只写已知字段」的唯一出口(B2)。
146
+ */
147
+ export function normalizePrefs(input: unknown): CodinputPrefs {
148
+ const raw = isRecord(input) ? input : {};
149
+ const prefs: CodinputPrefs = { ...DEFAULT_PREFS };
150
+
151
+ prefs.enabled = readBoolean(raw['enabled'], DEFAULT_PREFS.enabled);
152
+ prefs.userMarkdown = readBoolean(raw['userMarkdown'], DEFAULT_PREFS.userMarkdown);
153
+ prefs.lineNumbers = readBoolean(raw['lineNumbers'], DEFAULT_PREFS.lineNumbers);
154
+ // markdownMono 新字段:老用户存储里没有该键 → 合并式读取自动落到默认 true(v3 不升键)。
155
+ prefs.markdownMono = readBoolean(raw['markdownMono'], DEFAULT_PREFS.markdownMono);
156
+ const fontFamily = typeof raw['fontFamily'] === 'string' ? raw['fontFamily'].trim() : '';
157
+ if (fontFamily.length > 0 && fontFamily.length <= FONT_FAMILY_MAX) prefs.fontFamily = fontFamily;
158
+
159
+ prefs.sendHotkey = readHotkey(raw['sendHotkey'], DEFAULT_PREFS.sendHotkey);
160
+ prefs.lineBreakHotkey = readHotkey(raw['lineBreakHotkey'], DEFAULT_PREFS.lineBreakHotkey);
161
+ if (prefs.sendHotkey === prefs.lineBreakHotkey) {
162
+ // 冲突(含被写坏成同一键):两枚都回退默认,保证 send ≠ lineBreak。
163
+ prefs.sendHotkey = DEFAULT_PREFS.sendHotkey;
164
+ prefs.lineBreakHotkey = DEFAULT_PREFS.lineBreakHotkey;
165
+ }
166
+
167
+ const defaultEdit = readBoolean(raw['defaultEdit'], DEFAULT_PREFS.defaultEdit);
168
+ const defaultPreview = readBoolean(raw['defaultPreview'], DEFAULT_PREFS.defaultPreview);
169
+ if (defaultEdit || defaultPreview) {
170
+ prefs.defaultEdit = defaultEdit;
171
+ prefs.defaultPreview = defaultPreview;
172
+ }
173
+
174
+ prefs.composerBodyHeight = readPositive(raw['composerBodyHeight'], BODY_HEIGHT_MIN, BODY_HEIGHT_MAX);
175
+ const mode = raw['mode'];
176
+ prefs.mode =
177
+ mode === 'float' || mode === 'normal' ? mode : raw['floating'] === true ? 'float' : DEFAULT_PREFS.mode;
178
+ prefs.floatX = readPosition(raw['floatX']);
179
+ prefs.floatY = readPosition(raw['floatY']);
180
+ prefs.floatW = readPositive(raw['floatW'], FLOAT_W_MIN, FLOAT_SIZE_MAX);
181
+ prefs.floatH = readPositive(raw['floatH'], FLOAT_H_MIN, FLOAT_SIZE_MAX);
182
+ return prefs;
183
+ }
184
+
185
+ /**
186
+ * 一次性迁移旧字段(B1/B2):
187
+ * - `sendKey === 'enter'`(旧版 Enter 发送)→ send 'enter' + 换行 'shift+enter'
188
+ * (旧代码错写成 'shift-enter',分隔符不是 '+',导致 Shift+Enter 换行失效);
189
+ * - `sendKey` 为 mod-enter/ctrl-enter → 即新默认(Mod+Enter 发送、Enter 换行);
190
+ * - 只有目标快捷键缺失/非法时才用旧值迁移:用户已经改过的新字段优先;
191
+ * - 迁移后删除全部旧字段,存储里不再有旧键,后续加载不会再被迁移逻辑覆盖。
192
+ */
193
+ function migrateLegacy(raw: Record<string, unknown>): Record<string, unknown> {
194
+ if (!LEGACY_KEYS.some((key) => Object.prototype.hasOwnProperty.call(raw, key))) return raw;
195
+ const next: Record<string, unknown> = { ...raw };
196
+ const legacySend = typeof next['sendKey'] === 'string' ? next['sendKey'].trim().toLowerCase() : undefined;
197
+ if (next['mode'] !== 'float' && next['mode'] !== 'normal' && next['floating'] === true) {
198
+ next['mode'] = 'float';
199
+ }
200
+ if (!isValidHotkey(next['sendHotkey'])) {
201
+ if (legacySend === 'enter') {
202
+ next['sendHotkey'] = 'enter';
203
+ next['lineBreakHotkey'] = 'shift+enter';
204
+ } else if (
205
+ legacySend === 'mod-enter' ||
206
+ legacySend === 'mod+enter' ||
207
+ legacySend === 'ctrl-enter' ||
208
+ legacySend === 'ctrl+enter'
209
+ ) {
210
+ next['sendHotkey'] = 'mod+enter';
211
+ next['lineBreakHotkey'] = 'enter';
212
+ }
213
+ }
214
+ for (const key of LEGACY_KEYS) delete next[key];
215
+ return next;
216
+ }
217
+
218
+ /** 存储写/删的安全包装:返回是否成功(迁移删 v2 依赖写成功判断)。 */
219
+ function safeSet(key: string, value: string): boolean {
220
+ try {
221
+ window.localStorage.setItem(key, value);
222
+ return true;
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ function safeRemove(key: string): void {
229
+ try {
230
+ window.localStorage.removeItem(key);
231
+ } catch {
232
+ /* 删除失败无妨:新键已优先 */
233
+ }
234
+ }
235
+
236
+ /** 存储文本 → 合法偏好(坏 JSON/未知键/旧字段一并归一化,不写回)。 */
237
+ function decodePrefs(raw: string): CodinputPrefs {
238
+ let parsed: unknown = null;
239
+ try {
240
+ parsed = JSON.parse(raw);
241
+ } catch {
242
+ /* 坏 JSON:按全默认处理 */
243
+ }
244
+ return normalizePrefs(isRecord(parsed) ? migrateLegacy(parsed) : null);
245
+ }
246
+
247
+ /**
248
+ * 读取偏好:任何脏数据(坏 JSON、错误类型、未知键、越界值)都不抛错,
249
+ * 全部逐字段归一化回退默认(B3)。迁移/修复/清脏后与规范化文本不一致时
250
+ * 一次性写回(B2:旧字段删除、坏值修复、未知键不出现在存储里);之后的
251
+ * 加载 raw 与 canonical 相同,不再重复写入。
252
+ *
253
+ * v3 优先。v3 缺失而 v2 存在时做一次性升级迁移(v2 时代的坏值/旧字段在这里
254
+ * 归一化清理):归一化后写 v3,只有 v3 写成功才删 v2——写失败时保留 v2 兜底,
255
+ * 下次加载重试。
256
+ */
70
257
  export function loadPrefs(): CodinputPrefs {
71
258
  try {
72
259
  const raw = window.localStorage.getItem(KEY);
73
- if (!raw) return { ...DEFAULT_PREFS };
74
- const parsed = JSON.parse(raw);
75
- const merged: CodinputPrefs = { ...DEFAULT_PREFS, ...(parsed && typeof parsed === 'object' ? parsed : null) };
76
- const legacy = parsed as { sendKey?: string; lineBreakKey?: string; mode?: unknown; floating?: boolean };
77
- // 旧字段迁移:sendKey('mod-enter'|'enter') → sendHotkey/lineBreakHotkey。
78
- if (legacy.sendKey === 'enter') {
79
- merged.sendHotkey = 'enter';
80
- merged.lineBreakHotkey = 'shift-enter';
260
+ if (raw !== null) {
261
+ const prefs = decodePrefs(raw);
262
+ const canonical = JSON.stringify(prefs);
263
+ if (raw !== canonical) safeSet(KEY, canonical);
264
+ return prefs;
81
265
  }
82
- // 旧字段迁移:两态模型时代的 floating:true → mode:'float'。
83
- merged.mode =
84
- legacy.mode === 'float' || legacy.mode === 'normal'
85
- ? legacy.mode
86
- : legacy.floating === true
87
- ? 'float'
88
- : 'normal';
89
- return merged;
266
+ const legacyRaw = window.localStorage.getItem(LEGACY_KEY);
267
+ if (legacyRaw === null) return { ...DEFAULT_PREFS };
268
+ const prefs = decodePrefs(legacyRaw);
269
+ const canonical = JSON.stringify(prefs);
270
+ if (safeSet(KEY, canonical)) safeRemove(LEGACY_KEY);
271
+ return prefs;
90
272
  } catch {
91
273
  return { ...DEFAULT_PREFS };
92
274
  }
93
275
  }
94
276
 
277
+ /**
278
+ * 保存偏好:先与当前值合并再做同一套归一化(B3),只序列化已知字段(B2),
279
+ * 写入 v3。v3 写成功后再清掉可能残留的 v2(迁移写失败后由保存补上的场景;
280
+ * 写失败则保留 v2 兜底)。
281
+ */
95
282
  export function savePrefs(patch: Partial<CodinputPrefs>): CodinputPrefs {
96
- const next = { ...loadPrefs(), ...patch };
97
- try {
98
- window.localStorage.setItem(KEY, JSON.stringify(next));
99
- } catch {
100
- /* 存储不可用时仅内存生效 */
101
- }
283
+ const next = normalizePrefs({ ...loadPrefs(), ...(isRecord(patch) ? patch : {}) });
284
+ if (safeSet(KEY, JSON.stringify(next))) safeRemove(LEGACY_KEY);
102
285
  for (const fn of listeners) fn();
103
286
  return next;
104
287
  }
@@ -109,7 +109,8 @@ function Selector(props: {
109
109
  const { value, options, onChange, ariaLabel } = props;
110
110
  const [open, setOpen] = useState(false);
111
111
  const btnRef = useRef<HTMLButtonElement | null>(null);
112
- const [anchor, setAnchor] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
112
+ const [anchor, setAnchor] = useState<HTMLElement | null>(null);
113
+ const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
113
114
  const current = options.find((o) => o.value === value);
114
115
  return (
115
116
  <>
@@ -121,7 +122,11 @@ function Selector(props: {
121
122
  aria-expanded={open}
122
123
  aria-label={ariaLabel}
123
124
  onClick={() => {
124
- if (!open) setAnchor(btnRef.current?.getBoundingClientRect() ?? null);
125
+ if (!open) {
126
+ const el = btnRef.current ?? null;
127
+ setAnchor(el);
128
+ setAnchorRect(el ? el.getBoundingClientRect() : null);
129
+ }
125
130
  setOpen(!open);
126
131
  }}
127
132
  >
@@ -129,7 +134,7 @@ function Selector(props: {
129
134
  <IconChevronOfficial size={14} />
130
135
  </button>
131
136
  {open && anchor ? (
132
- <MenuSurface anchor={anchor} side="bottom" alignEnd onClose={() => setOpen(false)} minWidth={200}>
137
+ <MenuSurface anchor={anchor} initialRect={anchorRect} side="bottom" alignEnd onClose={() => setOpen(false)} minWidth={200}>
133
138
  {options.map((opt) => (
134
139
  <button
135
140
  key={opt.value}
@@ -186,6 +191,13 @@ export function CodinputSettingsSection(): JSX.Element {
186
191
  <Row title={t("set.lineNumbers")} desc={t("set.lineNumbersDesc")}>
187
192
  <Switch on={prefs.lineNumbers} label={t("set.lineNumbers")} onToggle={() => update({ lineNumbers: !prefs.lineNumbers })} />
188
193
  </Row>
194
+ <Row title={t("set.markdownMono")} desc={t("set.markdownMonoDesc")}>
195
+ <Switch
196
+ on={prefs.markdownMono}
197
+ label={t("set.markdownMono")}
198
+ onToggle={() => update({ markdownMono: !prefs.markdownMono })}
199
+ />
200
+ </Row>
189
201
  <Row title={t("set.font")} desc={t("set.fontDesc")}>
190
202
  <input
191
203
  type="text"
@@ -157,30 +157,90 @@ export function deriveChatStats(nodes: unknown): ChatStats | undefined {
157
157
  return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens };
158
158
  }
159
159
 
160
- /** 官方 formatTokensPerSecond:≥10 取整,<10 保留 1 位小数。 */
161
- export function formatTokensPerSecond(tps: number): string {
160
+ /** 官方 formatTokensPerSecond:≥10 取整,<10 保留 1 位小数;NaN/Infinity → null。 */
161
+ export function formatTokensPerSecond(tps: number): string | null {
162
+ if (!Number.isFinite(tps)) return null;
162
163
  const clamped = Math.max(0, tps);
163
164
  return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10);
164
165
  }
165
166
 
167
+ /** 官方 roundedPercentUnits:把缓存占比四舍五入到精确百分比单位(正半分进位)。 */
168
+ function roundedPercentUnits(cacheReadTokens: number, promptTokens: number, decimalPlaces: number): number {
169
+ const scale = (decimalPlaces === 0 ? 1 : 10) * 100;
170
+ const doubledScale = scale * 2;
171
+ const denominatorQuotient = Math.floor(promptTokens / doubledScale);
172
+ const denominatorRemainder = promptTokens % doubledScale;
173
+ let lower = 0;
174
+ let upper = scale;
175
+ while (lower < upper) {
176
+ const candidate = Math.floor((lower + upper + 1) / 2);
177
+ const factor = candidate * 2 - 1;
178
+ if (
179
+ cacheReadTokens >=
180
+ factor * denominatorQuotient + Math.ceil((factor * denominatorRemainder) / doubledScale)
181
+ ) {
182
+ lower = candidate;
183
+ } else {
184
+ upper = candidate - 1;
185
+ }
186
+ }
187
+ return lower;
188
+ }
189
+
190
+ /** 官方 displayPercentUnits:0 位 → 整数;>0 位 → 整数 + 一位小数(官方只支持 0/1)。 */
191
+ function displayPercentUnits(units: number, decimalPlaces: number): string {
192
+ if (decimalPlaces === 0) return String(units);
193
+ const whole = Math.floor(units / 10);
194
+ const tenths = units % 10;
195
+ return tenths === 0 ? String(whole) : `${whole}.${tenths}`;
196
+ }
197
+
166
198
  /**
167
- * 官方 formatCacheHitPercent 语义:整除 100 → "100";整数舍入 <100 → 整数;
168
- * 否则递增小数位直到舍入仍 <100(99.9 / 99.99 …)。
199
+ * 官方 formatCacheHitPercent 完整算法(0.2.2-rc.1):无计费输入 → null;
200
+ * 全命中 → "100";整数舍入仍 <100 → 整数文本;否则按官方「区分精度」规则
201
+ * 返回 99.9…(不把部分命中断章取义成 99.99 或 100)。NaN/Infinity → null。
202
+ *
203
+ * @param decimalPlaces 官方只使用 0 / 1 两档;非 0 一律按 1 位处理。
169
204
  */
170
- export function formatCacheHitPercent(cacheReadTokens: number, promptTokens: number): string | null {
171
- if (promptTokens <= 0) return null;
172
- if (cacheReadTokens >= promptTokens) return '100';
173
- for (let places = 0; places < 20; places++) {
174
- const factor = 10 ** places;
175
- const rounded = Math.round((cacheReadTokens / promptTokens) * 100 * factor) / factor;
176
- if (rounded < 100) return places === 0 ? String(rounded) : rounded.toFixed(places);
205
+ export function formatCacheHitPercent(
206
+ cacheReadTokens: number,
207
+ promptTokens: number,
208
+ decimalPlaces = 0,
209
+ ): string | null {
210
+ if (!Number.isFinite(cacheReadTokens) || !Number.isFinite(promptTokens)) return null;
211
+ const prompt = Math.trunc(promptTokens);
212
+ if (prompt <= 0) return null;
213
+ // 计数应为整数;脏值截断并夹到 [0, prompt],保证算法整数语义且不越界。
214
+ const cacheRead = Math.min(Math.max(0, Math.trunc(cacheReadTokens)), prompt);
215
+ const missedInputTokens = prompt - cacheRead;
216
+ if (missedInputTokens === 0) return '100';
217
+ const places = decimalPlaces === 0 ? 0 : 1;
218
+ const roundedUnits = roundedPercentUnits(cacheRead, prompt, places);
219
+ if (roundedUnits < (places === 0 ? 100 : 1e3)) return displayPercentUnits(roundedUnits, places);
220
+ let distinguishingPlaces = 1;
221
+ let scaledDoubleGap = missedInputTokens * 200;
222
+ const denominatorTens = Math.floor(prompt / 10);
223
+ while (scaledDoubleGap <= denominatorTens) {
224
+ scaledDoubleGap *= 10;
225
+ distinguishingPlaces += 1;
177
226
  }
178
- return '99.99';
227
+ const denominatorOnes = prompt % 10;
228
+ let roundedLoss = 5;
229
+ for (let loss = 1; loss < 5; loss += 1) {
230
+ const factor = loss * 2 + 1;
231
+ const threshold = factor * denominatorTens + Math.floor((factor * denominatorOnes) / 10);
232
+ if (scaledDoubleGap <= threshold) {
233
+ roundedLoss = loss;
234
+ break;
235
+ }
236
+ }
237
+ return `99.${'9'.repeat(distinguishingPlaces - 1)}${10 - roundedLoss}`;
179
238
  }
180
239
 
181
- /** 官方 formatDuration:<60s 一位小数秒,否则 分+秒。 */
182
- export function formatDuration(ms: number): string {
183
- const s = ms / 1000;
240
+ /** 官方 formatDuration:<60s 一位小数秒,否则 分+秒;NaN/Infinity → null。 */
241
+ export function formatDuration(ms: number): string | null {
242
+ if (!Number.isFinite(ms)) return null;
243
+ const s = Math.max(0, ms) / 1000;
184
244
  if (s < 60) return `${Math.round(s * 10) / 10}s`;
185
245
  const whole = Math.round(s);
186
246
  return `${Math.floor(whole / 60)}分${whole % 60}秒`;
@@ -260,11 +320,23 @@ function readTurnsSteps(k2: unknown, k3: unknown): ChatStats | undefined {
260
320
  return pick(k2) ?? pick(k3);
261
321
  }
262
322
 
263
- /** 官方 formatTokens:<1e3 原值,否则 K/M(≥100 取整、<100 一位小数)。 */
264
- export function formatTokens(tokens: number): string {
323
+ /**
324
+ * 官方紧凑 token 文本(517 / 12.2K / 1.2M,本地文案后缀 " tok"):
325
+ * <1e3 原值;<1e6 用 K(≥100 取整、<100 一位小数);≥1e6 用 M。
326
+ * K 位舍入到 1000 时进位到 M,永远不出现 "1000K"(如 999_950 → "1M")。
327
+ * NaN/Infinity 不渲染(返回 null)。
328
+ */
329
+ export function formatTokens(tokens: number): string | null {
330
+ if (!Number.isFinite(tokens)) return null;
331
+ const value = Math.max(0, tokens);
265
332
  const scaled = (candidate: number): string =>
266
333
  candidate >= 100 ? String(Math.round(candidate)) : String(Math.round(candidate * 10) / 10);
267
- if (tokens < 1e3) return `${tokens} tok`;
268
- if (tokens < 1e6) return `${scaled(tokens / 1e3)}K tok`;
269
- return `${scaled(tokens / 1e6)}M tok`;
334
+ if (value < 1e3) return `${String(value)} tok`;
335
+ if (value < 1e6) {
336
+ const thousands = value / 1e3;
337
+ const roundedK = thousands >= 100 ? Math.round(thousands) : Math.round(thousands * 10) / 10;
338
+ if (roundedK < 1e3) return `${String(roundedK)}K tok`;
339
+ return `${scaled(value / 1e6)}M tok`;
340
+ }
341
+ return `${scaled(value / 1e6)}M tok`;
270
342
  }
@@ -8,6 +8,9 @@
8
8
  * 写死保持逐值对齐——多跟一分就会在换肤下与官方脱队。
9
9
  */
10
10
 
11
+ import { markdownStyles } from './markdown';
12
+ import { heavyMarkdownStyles } from './markdown/heavy';
13
+
11
14
  const STYLE_ID = 'dsh-codinput-styles';
12
15
 
13
16
  const CSS = `
@@ -69,40 +72,49 @@ const CSS = `
69
72
  .dci-pos { position: absolute; right: 10px; bottom: 6px; font-size: 11px; color: var(--dsw-alias-label-caption, currentColor); opacity: 0.75;
70
73
  pointer-events: none; font-variant-numeric: tabular-nums; z-index: 3; }
71
74
 
72
- .dci-preview { flex: 1 1 100%; min-width: 0; box-sizing: border-box; overflow-y: auto; padding: 8px 14px 10px; font-size: 13px; line-height: 1.65; }
73
- .dci-preview h1, .dci-preview h2, .dci-preview h3, .dci-preview h4 { margin: 0.7em 0 0.35em; line-height: 1.3; }
74
- .dci-preview h1 { font-size: 1.35em; } .dci-preview h2 { font-size: 1.2em; } .dci-preview h3 { font-size: 1.08em; }
75
- .dci-preview p { margin: 0.45em 0; } .dci-preview ul, .dci-preview ol { margin: 0.45em 0; padding-left: 1.5em; }
76
- .dci-preview code { font-family: inherit; background: color-mix(in srgb, currentColor 9%, transparent);
77
- border-radius: 4px; padding: 0.1em 0.35em; font-size: 0.92em; }
78
- .dci-preview pre { background: color-mix(in srgb, currentColor 7%, transparent); border-radius: 8px;
79
- padding: 10px 12px; overflow-x: auto; } .dci-preview pre code { background: transparent; padding: 0; }
80
- .dci-preview blockquote { margin: 0.5em 0; padding: 0.1em 0.9em; border-left: 3px solid color-mix(in srgb, currentColor 22%, transparent); opacity: 0.85; }
81
- .dci-preview table { border-collapse: collapse; margin: 0.6em 0; }
82
- .dci-preview th, .dci-preview td { border: 1px solid color-mix(in srgb, currentColor 18%, transparent); padding: 4px 9px; }
83
- /* 预览链接走官方 markdown 同款 alias-link token(官方 _markdown_ a 逐值核对)。 */
84
- .dci-preview a { color: var(--dsw-alias-link, #4c8dff); } .dci-preview img { max-width: 100%; } .dci-preview hr { border: none;
85
- border-top: 1px solid color-mix(in srgb, currentColor 14%, transparent); }
86
-
87
- /* ===== 工具行(官方 InputBar .row / .tools / .trailing)===== */
88
- .dci-row { flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; min-width: 0; padding: 2px 8px 6px; display: flex; }
89
- .dci-tools { align-items: center; min-width: 0; gap: 12px; display: flex; }
90
- .dci-trailing { align-items: center; flex: none; min-width: 0; gap: 12px; margin-left: auto; display: flex; }
75
+ /* Markdown 排版(.dci-preview 预览排版 + .dci-md 扩展块)已迁到
76
+ src/client/markdown/styles.ts,由本文件末尾拼接 markdownStyles 注入。 */
77
+
78
+ /* ===== 工具行(官方 0.2.2 InputBar .row / .tools / .modes / .trailing 逐值)=====
79
+ row 兼作容器查询容器(官方 container-type:inline-size):模型紧凑态、权限
80
+ 触发器 @container (width<=460px) 收起标签等官方子组件规则都按行宽求值。
81
+ --dci-model-icon = 官方 primitives IconDataOutlineRegular(regular 描边 1、
82
+ viewBox 16、fill+stroke 双 path,逐字取自宿主 app.asar 的 icon 表)。 */
83
+ .dci-row { flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; min-width: 0; padding: 2px 8px 6px;
84
+ display: flex; container-type: inline-size;
85
+ --dci-model-icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2016%2016'%20fill%3D'none'%20stroke-width%3D'1'%3E%3Cpath%20d%3D'M7.8667%200.349609C8.96906%200.349634%2010.0601%200.481272%2011.0317%200.735352C11.9973%200.987845%2012.8453%201.362%2013.4644%201.84766C14.0744%202.32629%2014.507%202.95539%2014.5161%203.69336H14.5171V8.53516C14.0843%208.32076%2013.6108%208.17679%2013.1108%208.11816C13.1831%207.96848%2013.2162%207.82856%2013.2163%207.70312V5.76758C12.6269%206.16618%2011.8739%206.47995%2011.0317%206.7002C10.0602%206.95423%208.96896%207.08494%207.8667%207.08496C6.76461%207.08493%205.67411%206.95415%204.70264%206.7002C3.85994%206.48006%203.10694%206.1662%202.51709%205.76758V7.70312L2.521%207.78418C2.56374%208.19554%202.93361%208.74414%203.91357%209.23145C4.9281%209.73585%206.35004%2010.0371%207.8667%2010.0371C8.26373%2010.0371%208.6543%2010.0141%209.03271%209.97461C8.75596%2010.3799%208.54664%2010.8349%208.42041%2011.3232C8.23666%2011.3313%208.0518%2011.3369%207.8667%2011.3369C6.20108%2011.3369%204.57025%2011.01%203.33447%2010.3955C3.04163%2010.2499%202.76658%2010.0836%202.51709%209.90039V11.6738C2.51728%2012.1379%202.88589%2012.7556%203.92236%2013.292C4.93457%2013.8157%206.35342%2014.1289%207.8667%2014.1289C8.12318%2014.1289%208.37694%2014.1161%208.62646%2014.0986C8.82021%2014.5535%209.08999%2014.9682%209.41943%2015.3271C8.91285%2015.3934%208.39149%2015.4287%207.8667%2015.4287C6.19761%2015.4287%204.56379%2015.0869%203.32568%2014.4463C2.11244%2013.8185%201.21649%2012.8562%201.21631%2011.6738V3.76367C1.21595%203.74853%201.21438%203.733%201.21436%203.71777C1.21436%202.96917%201.65103%202.33053%202.26807%201.84668C2.88747%201.36112%203.73675%200.987685%204.70264%200.735352C5.67413%200.481376%206.76457%200.349636%207.8667%200.349609ZM7.8667%201.65039C6.86269%201.65042%205.88326%201.77028%205.03076%201.99316C4.17183%202.2176%203.50421%202.52956%203.06982%202.87012C2.65043%203.19909%202.52622%203.48898%202.51709%203.69336V3.74414C2.52719%203.94845%202.65185%204.23772%203.06982%204.56543C3.50425%204.90601%204.17172%205.21795%205.03076%205.44238C5.88326%205.66527%206.8627%205.78513%207.8667%205.78516C8.8707%205.78513%209.85015%205.66525%2010.7026%205.44238C11.5611%205.21787%2012.2286%204.9049%2012.6626%204.56445C13.0982%204.22252%2013.2163%203.9231%2013.2163%203.71777L13.2104%203.63574C13.1818%203.43623%2013.044%203.16941%2012.6626%202.87012C12.2286%202.52957%2011.5614%202.21773%2010.7026%201.99316C9.85009%201.77025%208.8708%201.65041%207.8667%201.65039Z'%20fill%3D'%23fff'%2F%3E%3Cpath%20d%3D'M12.8936%2010.0361L13.2061%2010.5566C13.2296%2010.5959%2013.2651%2010.6562%2013.3027%2010.707C13.3469%2010.7666%2013.4148%2010.8431%2013.5195%2010.9023C13.6244%2010.9617%2013.725%2010.9801%2013.7988%2010.9873C13.8619%2010.9934%2013.9318%2010.9932%2013.9775%2010.9932H14.6162L14.8896%2011.4502L14.5947%2011.9443C14.5698%2011.9859%2014.5312%2012.0483%2014.5029%2012.1084C14.4781%2012.1611%2014.4514%2012.2312%2014.4395%2012.3164L14.4326%2012.4072L14.4395%2012.4971C14.4514%2012.5825%2014.4781%2012.6532%2014.5029%2012.7061C14.5312%2012.7661%2014.5689%2012.8287%2014.5938%2012.8701L14.8896%2013.3633L14.6162%2013.8213H13.9775C13.9318%2013.8213%2013.8619%2013.821%2013.7988%2013.8271C13.7433%2013.8326%2013.6728%2013.8442%2013.5967%2013.875L13.5195%2013.9121C13.4148%2013.9714%2013.3469%2014.0478%2013.3027%2014.1074C13.265%2014.1583%2013.2296%2014.2186%2013.2061%2014.2578L12.8936%2014.7783H12.3115L11.999%2014.2578C11.9755%2014.2186%2011.9401%2014.1583%2011.9023%2014.1074C11.8693%2014.0628%2011.823%2014.0083%2011.7578%2013.959L11.6855%2013.9121L11.6074%2013.875C11.5316%2013.8445%2011.4615%2013.8325%2011.4062%2013.8271C11.3432%2013.821%2011.2733%2013.8213%2011.2275%2013.8213H10.5889L10.3135%2013.3633L10.6104%2012.8701C10.6352%2012.8287%2010.6739%2012.7661%2010.7021%2012.7061C10.7352%2012.6357%2010.7724%2012.534%2010.7725%2012.4072C10.7724%2012.2804%2010.7352%2012.1788%2010.7021%2012.1084C10.6739%2012.0483%2010.6353%2011.9859%2010.6104%2011.9443L10.3135%2011.4502L10.5889%2010.9932H11.2275C11.2733%2010.9932%2011.3432%2010.9934%2011.4062%2010.9873C11.4801%2010.9801%2011.5808%2010.9616%2011.6855%2010.9023C11.7903%2010.843%2011.8582%2010.7666%2011.9023%2010.707C11.94%2010.6562%2011.9755%2010.5959%2011.999%2010.5566L12.3115%2010.0361H12.8936Z'%20stroke%3D'%23fff'%20stroke-miterlimit%3D'10'%2F%3E%3C%2Fsvg%3E"); }
86
+ /* 官方 data-model-compact(observeControlRow):隐藏模型文字/强度,显示图标。 */
87
+ .dci-row[data-model-compact] .dci-trigger[data-model] .dci-triggerLabel,
88
+ .dci-row[data-model-compact] .dci-trigger[data-model] .dci-triggerEffort { display: none; }
89
+ .dci-row[data-model-compact] .dci-trigger[data-model]::before { content: ''; flex: none; width: 16px; height: 16px;
90
+ background-color: currentColor; -webkit-mask: var(--dci-model-icon) center / 16px 16px no-repeat;
91
+ mask: var(--dci-model-icon) center / 16px 16px no-repeat; }
92
+ .dci-tools, .dci-modes, .dci-trailing { align-items: center; min-width: 0; display: flex; }
93
+ .dci-tools, .dci-modes { gap: 12px; }
94
+ .dci-trailing { flex: none; gap: 12px; margin-left: auto; }
95
+ @container (max-width: 560px) { .dci-tools, .dci-modes, .dci-trailing { gap: 8px; } }
96
+ /* 官方 [hidden] 兜底:.tools/.standardControls 的 display:flex 是类选择器,
97
+ 会盖过 UA 的 [hidden]{display:none},官方 CSS 显式补同款规则。 */
98
+ .dci-tools[hidden], .dci-standard-controls[hidden] { display: none; }
99
+ .dci-trailing-active { flex: 1; margin-left: 0; }
91
100
 
92
101
  /* ===== 语音输入座位(官方 .standardControls / .activity / .activityExpanded)=====
93
102
  按钮本体是官方 activity 槽位条目组件(Gyyz9a_* 样式由语音插件自带注入);
94
- 这里只负责座位:展开时 capture 行接管整行、左侧工具与模型控件让位。 */
103
+ 这里只负责座位:展开时 capture 行接管整行、左侧工具与模型控件让位
104
+ (官方 hidden 属性隐藏,见 Toolbar)。
105
+ 注意:官方 renderSlot 即使槽位无占用者也会输出 data-slot 包装(display:contents),
106
+ 所以官方 .activity 永远不会命中 :empty ——实测(0.2.2 隔离宿主)无 voice 时
107
+ activity 是 display:flex 的 0×0 flex item,仍占一个 row gap(官方 trailing=306.6;
108
+ 若隐藏则 294.6,少 12px)。因此这里不能加 :empty{display:none};「旧宿主没有
109
+ 该槽位」的情况由 Toolbar 用 useSlotDeclaration 决定是否渲染座位。 */
95
110
  .dci-standard-controls { align-items: center; min-width: 0; gap: 12px; display: flex; }
96
111
  .dci-voice-seat { flex: none; align-items: center; display: flex; }
97
- .dci-voice-seat:empty { display: none; }
98
- .dci-row[data-voice-active] .dci-tools, .dci-row[data-voice-active] .dci-standard-controls { display: none; }
99
- .dci-row[data-voice-active] .dci-trailing { flex: 1; margin-left: 0; }
100
- .dci-row[data-voice-active] .dci-voice-seat { flex: 1; min-width: 0; }
112
+ .dci-voice-seat-expanded { flex: 1; min-width: 0; }
101
113
 
102
114
  /* 圆形图标按钮(官方 .add)*/
103
115
  .dci-add { background: var(--dsw-specific-selector, color-mix(in srgb, currentColor 8%, transparent));
104
116
  width: 28px; height: 28px; color: var(--dsw-alias-label-primary, currentColor); cursor: pointer; border: none;
105
- border-radius: 999px; flex: none; place-items: center; display: grid; }
117
+ border-radius: 999px; corner-shape: round; flex: none; place-items: center; display: grid; }
106
118
  .dci-add:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-solid, color-mix(in srgb, currentColor 14%, transparent)); }
107
119
  .dci-add:disabled { opacity: 0.5; cursor: default; }
108
120
 
@@ -125,10 +137,15 @@ const CSS = `
125
137
 
126
138
  /* 发送(官方 .primary)*/
127
139
  .dci-send { background: var(--dsw-alias-button-info-fill, color-mix(in srgb, currentColor 85%, transparent)); color: #fff; cursor: pointer;
128
- border: none; border-radius: 999px; flex: none; place-items: center; width: 34px; height: 34px;
140
+ border: none; border-radius: 999px; corner-shape: round; flex: none; place-items: center; width: 34px; height: 34px;
129
141
  transition: background-color 0.1s; display: grid; transform: translateY(-2px); }
130
142
  .dci-send:hover:not(:disabled) { background: var(--dsw-alias-button-info-hover, color-mix(in srgb, currentColor 70%, transparent)); }
131
143
  .dci-send:disabled { opacity: 0.4; cursor: default; }
144
+ /* 官方 .primary[data-sending]:disabled —— sending spinner 态不降透明度、光标 progress。 */
145
+ .dci-send[data-sending]:disabled { opacity: 1; cursor: progress; }
146
+ .dci-sending-spinner { animation: 0.8s linear infinite dci-input-sending; }
147
+ @media (prefers-reduced-motion: reduce) { .dci-sending-spinner { animation-duration: 2.4s; } }
148
+ @keyframes dci-input-sending { to { transform: rotate(360deg); } }
132
149
 
133
150
  /* ===== 菜单(官方 primitives Menu / ModelSelect .menu 逐值复刻:r20 卡片 / item r10 / 上方弹出)
134
151
  卡片 overflow:hidden + max-height:min(360px,100vh-96px)(官方 .menu 同款),
@@ -168,6 +185,11 @@ const CSS = `
168
185
  max-height: 400px; box-shadow: var(--dsw-elevation-prominent, 0 10px 34px rgba(0, 0, 0, 0.35));
169
186
  border: 0; border-radius: var(--dsw-radius-lg, 20px); flex-direction: column; padding: 4px; display: flex; position: absolute;
170
187
  bottom: calc(100% + 4px); left: 0; right: 0; overflow: hidden; }
188
+ /* dropdown(侧栏/悬浮形态):portal 到 body 的自适应浮层——位置/宽度/最大高度由
189
+ SlashMenu 量卡片(.dci-card)与视口后内联注入;fixed 不受面板/卡片裁剪,层级盖过
190
+ 悬浮卡(.dci-float z-index:940)但低于模态/灯箱(1000)。normal 形态仍走上面的
191
+ 卡片顶锚定 absolute 规则,几何不变。 */
192
+ .dci-slash-menu[data-dropdown] { position: fixed; z-index: 950; left: auto; right: auto; top: auto; bottom: auto; }
171
193
  .dci-slash-viewport { flex-direction: column; min-height: 0; display: flex; overflow-y: auto; }
172
194
  .dci-slash-item { cursor: pointer; width: 100%; min-height: 34px; color: var(--dsw-alias-label-primary, inherit);
173
195
  text-align: left; background: transparent; border: none; border-radius: var(--dsw-radius-md, 10px); align-items: center; gap: 6px;
@@ -391,8 +413,8 @@ button.dci-attach-body { color: inherit; font: inherit; text-align: left; backgr
391
413
  .dci-surface[data-side] .dci-body { flex: 1 1 auto; max-height: none; min-height: 0; }
392
414
  .dci-surface[data-side] .dci-row { padding: 6px 8px;
393
415
  border-top: 0.5px solid var(--dsw-alias-border-l1, transparent); }
394
- /* 候选菜单向下弹出(侧栏/悬浮形态:头部行下方),留在卡片边界内不被裁剪。 */
395
- .dci-surface[data-side] .dci-slash-menu, .dci-surface[data-float] .dci-slash-menu { bottom: auto; }
416
+ /* 候选菜单的 side/float 形态已改为 portal 自适应浮层(见 .dci-slash-menu[data-dropdown]),
417
+ 不再挂在 surface 下,故此处不再需要布局覆盖。 */
396
418
 
397
419
  /* ===== 设置分区(官方 T1PP 行结构 + 控件样式)===== */
398
420
  .dci-settings { display: flex; flex-direction: column; font-size: 14px; color: inherit; }
@@ -641,7 +663,10 @@ button.dci-attach-body { color: inherit; font: inherit; text-align: left; backgr
641
663
  border-radius: var(--dsw-radius-sm); padding: 0 5px; }
642
664
  .dci-umsg-md :where(h1, h2, h3, h4, h5, h6) code { font: inherit; font-family: var(--ds-font-family-code); }
643
665
  .dci-umsg-md input[type='checkbox'] { margin: 0 8px 0 0; accent-color: var(--dsw-alias-label-secondary); }
644
- .dci-umsg-md img { display: block; width: auto; max-width: 100%; height: auto; margin: 0;
666
+ /* 用户消息图片:width 由统一管线按 Obsidian 尺寸语法(![alt|120](url))写成
667
+ width 属性;这里不能写 width:auto(CSS 会覆盖 presentational hint,尺寸语法
668
+ 在用户消息侧失效)。只保留 max-width 约束,未带 width 的图片行为不变。 */
669
+ .dci-umsg-md img { display: block; max-width: 100%; height: auto; margin: 0;
645
670
  border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-base); object-fit: contain; }
646
671
  .dci-umsg-tscroll { max-width: 100%; overflow-x: auto; overscroll-behavior-x: contain; }
647
672
  .dci-umsg-tscroll table { border-collapse: collapse; width: max-content; max-width: max-content; }
@@ -665,7 +690,7 @@ button.dci-umsg-chip:hover, button.dci-umsg-chip:focus { outline: none; text-dec
665
690
  button.dci-umsg-chip:focus-visible { box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); }
666
691
  .dci-umsg-chip[data-chip="slash"] { font-family: var(--dsw-font-markdown-code-font-family); }
667
692
  .dci-umsg-chipicon { width: 1em; height: 1em; margin-right: 4px; vertical-align: -0.125em; }
668
- `;
693
+ ` + markdownStyles + heavyMarkdownStyles;
669
694
 
670
695
  export function injectStyles(): void {
671
696
  const el = document.getElementById(STYLE_ID);