dsh-codinput 0.3.7 → 0.3.8

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-codinput",
3
- "version": "0.3.7",
4
- "description": "DSH WebUI 插件:把聊天输入框变成代码编辑器一样的输入风格(CodeMirror 6 接管 conversation.composer.bar)。",
3
+ "version": "0.3.8",
4
+ "description": "DSH 插件:把聊天输入框变成代码编辑器一样的输入风格(CodeMirror 6 接管 conversation.composer.bar),web 与桌面端通用。",
5
5
  "license": "MIT",
6
6
  "author": "Witherwithwinter",
7
7
  "icon": "./assets/icon.png",
@@ -1,9 +1,14 @@
1
1
  /** 数据行(卡片下方):官方 composer dock 复刻——两枚胶囊(仪表盘「轮/步 · tok/s」、
2
2
  * 数据库「累计 tok · 缓存命中」)+ 上下文小圈(0.1.7-rc.2 起官方把它挪进本行,圈+百分比
3
- * 药丸),点击弹出官方同构详情面板(会话统计 / Token 用量 / 上下文分段),锚点上方弹出、
4
- * 点外关闭。 */
3
+ * 药丸),点击弹出官方同构详情面板(会话统计 / Token 用量 / 上下文分段)。面板照官方
4
+ * ContextMeter / useStatDialog 机制:portal 到 body + fixed,经 useAnchoredPanel(官方
5
+ * useAnchoredPosition 插件侧移植)按锚点视口矩形定位(side top / align start、gap 8、
6
+ * 视口 margin 12 夹取,滚动(捕获)/resize/面板尺寸变化重算);每枚面板一个
7
+ * useStatDialogSeat(锚点/面板 ref + 定位 + 点外与 Escape 关闭),三枚共用 StatsRow 的
8
+ * 单一展开态互斥(与官方 chat StatsPills 的 openPill 同语义)。 */
5
9
 
6
- import { useEffect, useRef, useState } from 'react';
10
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
11
+ import { createPortal } from 'react-dom';
7
12
  import { useSessionStats, formatTokens, formatTokensPerSecond, formatCacheHitPercent, formatDuration, type SessionStats } from '../stats';
8
13
  import type { UseProjection } from '../host-types';
9
14
  import { t } from '../i18n';
@@ -37,16 +42,114 @@ export function DatabaseIcon({ size = 14 }: { size?: number }): JSX.Element {
37
42
  );
38
43
  }
39
44
 
40
- /** 官方详情面板(bRhRbq:标题行 + 发丝线 + dt/dd 栅格)。 */
45
+ /** 官方 useAnchoredPosition 的插件侧移植(primitives 不可 import):fixed 面板按锚点
46
+ * 视口矩形定位(side top / align start:面板左缘对齐锚点左缘、底距锚点顶 gap),
47
+ * 两侧按 margin 夹取(顶边用官方 overlayTopMargin:帧顶 clearance+20 与 margin 取大),
48
+ * 滚动(捕获相位)/resize/面板自身尺寸变化重算。返回 null = 尚未量得(面板隐藏)。 */
49
+ function useAnchoredPanel(
50
+ open: boolean,
51
+ anchorRef: React.RefObject<HTMLElement | null>,
52
+ panelRef: React.RefObject<HTMLElement | null>,
53
+ gap: number,
54
+ margin: number,
55
+ ): { left: number; top: number } | null {
56
+ const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
57
+ useLayoutEffect(() => {
58
+ if (!open) {
59
+ setPosition(null);
60
+ return;
61
+ }
62
+ // 官方 overlayTopMargin:帧顶 clearance(data-fullscreen 归零)+20,与 margin 取大。
63
+ const topMargin = (): number => {
64
+ const clearance = Number.parseFloat(
65
+ getComputedStyle(document.documentElement).getPropertyValue('--dsh-frame-top-clearance'),
66
+ );
67
+ if (Number.isNaN(clearance)) return margin;
68
+ return Math.max(margin, (document.documentElement.hasAttribute('data-fullscreen') ? 0 : clearance) + 20);
69
+ };
70
+ // 与官方 place() 同构:rect 存在就落位,宽/高为 0 时只跳过对应轴的夹取。
71
+ const place = (): void => {
72
+ const rect = anchorRef.current?.getBoundingClientRect();
73
+ if (rect === undefined) return;
74
+ const panel = panelRef.current;
75
+ const width = panel?.offsetWidth ?? 0;
76
+ const height = panel?.offsetHeight ?? 0;
77
+ let left = rect.left;
78
+ let top = rect.top - gap - height;
79
+ if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin);
80
+ if (height > 0) top = Math.min(Math.max(top, topMargin()), window.innerHeight - height - margin);
81
+ setPosition((prev) => (prev && prev.left === left && prev.top === top ? prev : { left, top }));
82
+ };
83
+ place();
84
+ window.addEventListener('scroll', place, true);
85
+ window.addEventListener('resize', place);
86
+ const panel = panelRef.current;
87
+ let observer: ResizeObserver | null = null;
88
+ if (typeof ResizeObserver !== 'undefined' && panel !== null) {
89
+ observer = new ResizeObserver(place);
90
+ observer.observe(panel);
91
+ }
92
+ return () => {
93
+ observer?.disconnect();
94
+ window.removeEventListener('scroll', place, true);
95
+ window.removeEventListener('resize', place);
96
+ };
97
+ }, [open, anchorRef, panelRef, gap, margin]);
98
+ return position;
99
+ }
100
+
101
+ /** 官方 useStatDialog 的插件侧等价 seat(primitives/chat 的 useStatDialog 不可 import):
102
+ * 每枚面板自持锚点/面板 ref + 定位,并在打开期间挂「点外关闭 + Escape 关闭」(官方语义:
103
+ * 锚点或 portal 面板内的 pointerdown 算「内」;Escape 走 document 冒泡)。父级用受控
104
+ * open 维持三枚面板互斥,与官方 chat StatsPills 的 openPill 同构。 */
105
+ interface StatDialogSeat {
106
+ rootRef: React.MutableRefObject<HTMLSpanElement | null>;
107
+ panelRef: React.MutableRefObject<HTMLDivElement | null>;
108
+ position: { left: number; top: number } | null;
109
+ }
110
+
111
+ function useStatDialogSeat(open: boolean, onClose: () => void): StatDialogSeat {
112
+ const rootRef = useRef<HTMLSpanElement | null>(null);
113
+ const panelRef = useRef<HTMLDivElement | null>(null);
114
+ const position = useAnchoredPanel(open, rootRef, panelRef, 8, 12);
115
+ useEffect(() => {
116
+ if (!open) return;
117
+ const onDown = (event: PointerEvent): void => {
118
+ if (!(event.target instanceof Node)) return;
119
+ if (rootRef.current?.contains(event.target) === true) return;
120
+ if (panelRef.current?.contains(event.target) === true) return;
121
+ onClose();
122
+ };
123
+ const onKeyDown = (event: KeyboardEvent): void => {
124
+ if (event.key === 'Escape') onClose();
125
+ };
126
+ document.addEventListener('pointerdown', onDown);
127
+ document.addEventListener('keydown', onKeyDown);
128
+ return () => {
129
+ document.removeEventListener('pointerdown', onDown);
130
+ document.removeEventListener('keydown', onKeyDown);
131
+ };
132
+ }, [open, onClose]);
133
+ return { rootRef, panelRef, position };
134
+ }
135
+
136
+ /** 面板定位样式(未量得前官方同款:隐藏在原点)。 */
137
+ function panelStyle(position: { left: number; top: number } | null): React.CSSProperties {
138
+ return position ?? { visibility: 'hidden', left: 0, top: 0 };
139
+ }
140
+
141
+ /** 官方详情面板(bRhRbq:标题行 + 发丝线 + dt/dd 栅格)。portal 到 body,fixed 定位。 */
41
142
  function StatPanel(props: {
42
143
  label: string;
43
144
  icon: JSX.Element;
44
145
  titleValue?: string;
45
146
  rows: readonly [string, string][];
147
+ panelRef: React.MutableRefObject<HTMLDivElement | null>;
148
+ position: { left: number; top: number } | null;
46
149
  }): JSX.Element {
47
- const { label, icon, titleValue, rows } = props;
48
- return (
49
- <div className="dci-stat-panel" role="dialog" aria-label={label}>
150
+ const { label, icon, titleValue, rows, panelRef, position } = props;
151
+ return createPortal(
152
+ <div className="dci-stat-panel" role="dialog" aria-label={label} ref={panelRef} style={panelStyle(position)}>
50
153
  <div className="dci-stat-panelTitle">
51
154
  <span className="dci-stat-panelLabel">
52
155
  {icon}
@@ -63,7 +166,8 @@ function StatPanel(props: {
63
166
  </div>
64
167
  ))}
65
168
  </dl>
66
- </div>
169
+ </div>,
170
+ document.body,
67
171
  );
68
172
  }
69
173
 
@@ -76,8 +180,9 @@ function exactCount(value: number | undefined): string {
76
180
  }
77
181
 
78
182
  /** 官方 TimePill:轮/步 + · tok/s(decodeMs>0 才出),面板 = 模型/工具用时、TTFT 均值、TPS。 */
79
- function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }): JSX.Element {
80
- const { stats, open, onToggle } = props;
183
+ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void; onClose(): void }): JSX.Element {
184
+ const { stats, open, onToggle, onClose } = props;
185
+ const { rootRef, panelRef, position } = useStatDialogSeat(open, onClose);
81
186
  const roundsPart = t('stats.turns', { turns: stats.turns ?? 0, steps: stats.steps ?? 0 });
82
187
  const tps =
83
188
  (stats.decodeMs ?? 0) > 0 ? formatTokensPerSecond((stats.decodeTokens ?? 0) / ((stats.decodeMs ?? 1) / 1000)) : null;
@@ -97,7 +202,7 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }):
97
202
  // 官方:无任何计时数据时胶囊不可点(无面板可看)。
98
203
  if (!hasTimings) {
99
204
  return (
100
- <span className="dci-stat-anchor">
205
+ <span className="dci-stat-anchor" ref={rootRef}>
101
206
  <span className="dci-stat-pill">
102
207
  <GaugeIcon />
103
208
  {label}
@@ -106,7 +211,7 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }):
106
211
  );
107
212
  }
108
213
  return (
109
- <span className="dci-stat-anchor">
214
+ <span className="dci-stat-anchor" ref={rootRef}>
110
215
  <button
111
216
  type="button"
112
217
  className="dci-stat-pill"
@@ -122,6 +227,8 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }):
122
227
  <StatPanel
123
228
  label={t("stats.session")}
124
229
  icon={<GaugeIcon />}
230
+ panelRef={panelRef}
231
+ position={position}
125
232
  rows={[
126
233
  [t('stats.modelTime'), (stats.llmMs ?? 0) > 0 ? formatDuration(stats.llmMs!) : '—'],
127
234
  [t('stats.toolTime'), (stats.toolMs ?? 0) > 0 ? formatDuration(stats.toolMs!) : '—'],
@@ -140,8 +247,9 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }):
140
247
  }
141
248
 
142
249
  /** 官方 UsagePill:总量 · 缓存命中(缓存命中用官方百分比精度规则)。 */
143
- function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void }): JSX.Element | null {
144
- const { stats, open, onToggle } = props;
250
+ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void; onClose(): void }): JSX.Element | null {
251
+ const { stats, open, onToggle, onClose } = props;
252
+ const { rootRef, panelRef, position } = useStatDialogSeat(open, onClose);
145
253
  if (stats.totalTokens === undefined) return null;
146
254
  const total = stats.totalTokens;
147
255
  const cacheHitText =
@@ -151,7 +259,7 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
151
259
  (stats.cacheReadTokens ?? 0) + (stats.cacheWriteTokens ?? 0) + (stats.uncachedInputTokens ?? 0),
152
260
  );
153
261
  return (
154
- <span className="dci-stat-anchor">
262
+ <span className="dci-stat-anchor" ref={rootRef}>
155
263
  <button
156
264
  type="button"
157
265
  className="dci-stat-pill"
@@ -180,6 +288,8 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
180
288
  label={t("stats.usage")}
181
289
  icon={<DatabaseIcon />}
182
290
  titleValue={exactCount(total)}
291
+ panelRef={panelRef}
292
+ position={position}
183
293
  rows={[
184
294
  [t('stats.cacheHitLabel'), cacheHitText !== null ? `${cacheHitText}%` : '—'],
185
295
  [t('stats.uncached'), exactCount(stats.uncachedInputTokens)],
@@ -198,40 +308,40 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
198
308
  export function StatsRow(props: StatsRowProps): JSX.Element | null {
199
309
  const { useProjection, sessionId, chatNodes } = props;
200
310
  // 唯一展开:三枚(轮步/用量/上下文小圈)共用一个 open 态——任意一枚展开
201
- // 即收起其余(官方语义:详情面板同时只存在一个)。
311
+ // 即收起其余(官方 chat StatsPills 的 openPill 同语义:详情面板同时只存在一个)。
202
312
  const [open, setOpen] = useState<'rounds' | 'tokens' | 'gauge' | null>(null);
203
- const rootRef = useRef<HTMLDivElement | null>(null);
313
+ const close = useCallback(() => setOpen(null), []);
314
+ const toggle = useCallback((key: 'rounds' | 'tokens' | 'gauge') => {
315
+ setOpen((current) => (current === key ? null : key));
316
+ }, []);
204
317
  const stats = useSessionStats(useProjection, sessionId, chatNodes);
205
318
 
206
- // 点外面关闭(面板内/胶囊上不关)。
207
- useEffect(() => {
208
- if (open === null) return;
209
- const onDown = (event: PointerEvent): void => {
210
- if (rootRef.current && event.target instanceof Node && rootRef.current.contains(event.target)) return;
211
- setOpen(null);
212
- };
213
- window.addEventListener('pointerdown', onDown, true);
214
- return () => window.removeEventListener('pointerdown', onDown, true);
215
- }, [open]);
216
-
217
319
  // 官方 rc.2:上下文小圈独立于 stats 胶囊渲染(无 stats 也要出圈)。
218
320
  const pressure = useProjection
219
321
  ? (useProjection('contextPressure') as PressureSnapshot | undefined)
220
322
  : undefined;
221
323
  const hasGauge = pressure !== undefined && !!pressure.contextWindow;
324
+ // 面板打开期间其数据源消失(切换会话 / 投影短暂缺席)时自动收起,避免恢复后「自己重开」。
325
+ useEffect(() => {
326
+ if (open === null) return;
327
+ const valid = open === 'gauge' ? hasGauge : stats !== null && stats !== undefined;
328
+ if (!valid) setOpen(null);
329
+ }, [open, hasGauge, stats]);
330
+
222
331
  if (!stats && !hasGauge) return null;
223
332
  return (
224
- <div className="dci-stat-root" data-composer-stats="codinput" ref={rootRef}>
333
+ <div className="dci-stat-root" data-composer-stats="codinput">
225
334
  {stats ? (
226
335
  <>
227
- <Pill stats={stats} open={open === 'rounds'} onToggle={() => setOpen(open === 'rounds' ? null : 'rounds')} />
228
- <TokenPill stats={stats} open={open === 'tokens'} onToggle={() => setOpen(open === 'tokens' ? null : 'tokens')} />
336
+ <Pill stats={stats} open={open === 'rounds'} onToggle={() => toggle('rounds')} onClose={close} />
337
+ <TokenPill stats={stats} open={open === 'tokens'} onToggle={() => toggle('tokens')} onClose={close} />
229
338
  </>
230
339
  ) : null}
231
340
  <ContextGauge
232
341
  useProjection={useProjection}
233
342
  open={open === 'gauge'}
234
- onToggle={() => setOpen(open === 'gauge' ? null : 'gauge')}
343
+ onToggle={() => toggle('gauge')}
344
+ onClose={close}
235
345
  />
236
346
  </div>
237
347
  );
@@ -259,14 +369,16 @@ function shortTokens(n: number): string {
259
369
 
260
370
  /** 上下文占用小圈(官方 JObwrW 复刻):双环仪表 + 百分比,点击展开分段面板。
261
371
  * 0.1.7-rc.2 起官方渲染在 dock 行 stats 胶囊之后(原先在工具行 trailing)。
262
- * 展开态由 StatsRow 持有(三枚详情面板唯一展开)。 */
372
+ * 展开态由 StatsRow 持有(三枚详情面板唯一展开);面板 portal 到 body +
373
+ * useAnchoredPanel 定位(官方 ContextMeter:side top / align start / gap 8 / margin 12)。 */
263
374
  export function ContextGauge(props: {
264
375
  useProjection: UseProjection | undefined;
265
376
  open: boolean;
266
377
  onToggle(): void;
378
+ onClose(): void;
267
379
  }): JSX.Element | null {
268
- const { useProjection, open, onToggle } = props;
269
- const rootRef = useRef<HTMLSpanElement | null>(null);
380
+ const { useProjection, open, onToggle, onClose } = props;
381
+ const { rootRef, panelRef, position } = useStatDialogSeat(open, onClose);
270
382
  const pressure = useProjection
271
383
  ? (useProjection('contextPressure') as PressureSnapshot | undefined)
272
384
  : undefined;
@@ -307,49 +419,58 @@ export function ContextGauge(props: {
307
419
  </svg>
308
420
  <span>{Math.round(pct)}%</span>
309
421
  </button>
310
- {open ? (
311
- <div className="dci-gauge-panel" role="dialog" aria-label={t("tool.contextTitle")}>
312
- <div className="dci-gauge-header">
313
- <span className="dci-gauge-headline">{t("tool.contextTitle")}</span>
314
- <span className="dci-gauge-percent">{Math.round(pct)}%</span>
315
- <span className="dci-gauge-figures">
316
- ~{shortTokens(usedTokens)} / {shortTokens(windowTokens)}
317
- </span>
318
- </div>
319
- <div className="dci-gauge-bar">
320
- {segments.map(([cls, tokens]) => (
321
- <div
322
- key={cls}
323
- className={`dci-gauge-segment ${cls}`}
324
- style={{ width: `${(tokens / windowTokens) * 100}%` }}
325
- />
326
- ))}
327
- </div>
328
- <dl className="dci-gauge-rows">
329
- <div className="dci-gauge-row">
330
- <dt>
331
- <span className={`dci-gauge-swatch dci-gauge-colorSystem`} aria-hidden="true" />
332
- {t("stats.prompt")}
333
- </dt>
334
- <dd>~{shortTokens(breakdown?.systemTokens ?? 0)}</dd>
335
- </div>
336
- <div className="dci-gauge-row">
337
- <dt>
338
- <span className={`dci-gauge-swatch dci-gauge-colorTools`} aria-hidden="true" />
339
- {t("stats.tools")}
340
- </dt>
341
- <dd>~{shortTokens(breakdown?.toolsTokens ?? 0)}</dd>
342
- </div>
343
- <div className="dci-gauge-row">
344
- <dt>
345
- <span className={`dci-gauge-swatch dci-gauge-colorMessages`} aria-hidden="true" />
346
- {t("stats.messages")}
347
- </dt>
348
- <dd>~{shortTokens(breakdown?.messageTokens ?? 0)}</dd>
349
- </div>
350
- </dl>
351
- </div>
352
- ) : null}
422
+ {open
423
+ ? createPortal(
424
+ <div
425
+ className="dci-gauge-panel"
426
+ role="dialog"
427
+ aria-label={t("tool.contextTitle")}
428
+ ref={panelRef}
429
+ style={panelStyle(position)}
430
+ >
431
+ <div className="dci-gauge-header">
432
+ <span className="dci-gauge-headline">{t("tool.contextTitle")}</span>
433
+ <span className="dci-gauge-percent">{Math.round(pct)}%</span>
434
+ <span className="dci-gauge-figures">
435
+ ~{shortTokens(usedTokens)} / {shortTokens(windowTokens)}
436
+ </span>
437
+ </div>
438
+ <div className="dci-gauge-bar">
439
+ {segments.map(([cls, tokens]) => (
440
+ <div
441
+ key={cls}
442
+ className={`dci-gauge-segment ${cls}`}
443
+ style={{ width: `${(tokens / windowTokens) * 100}%` }}
444
+ />
445
+ ))}
446
+ </div>
447
+ <dl className="dci-gauge-rows">
448
+ <div className="dci-gauge-row">
449
+ <dt>
450
+ <span className={`dci-gauge-swatch dci-gauge-colorSystem`} aria-hidden="true" />
451
+ {t("stats.prompt")}
452
+ </dt>
453
+ <dd>~{shortTokens(breakdown?.systemTokens ?? 0)}</dd>
454
+ </div>
455
+ <div className="dci-gauge-row">
456
+ <dt>
457
+ <span className={`dci-gauge-swatch dci-gauge-colorTools`} aria-hidden="true" />
458
+ {t("stats.tools")}
459
+ </dt>
460
+ <dd>~{shortTokens(breakdown?.toolsTokens ?? 0)}</dd>
461
+ </div>
462
+ <div className="dci-gauge-row">
463
+ <dt>
464
+ <span className={`dci-gauge-swatch dci-gauge-colorMessages`} aria-hidden="true" />
465
+ {t("stats.messages")}
466
+ </dt>
467
+ <dd>~{shortTokens(breakdown?.messageTokens ?? 0)}</dd>
468
+ </div>
469
+ </dl>
470
+ </div>,
471
+ document.body,
472
+ )
473
+ : null}
353
474
  </span>
354
475
  );
355
476
  }