dsh-codinput 0.1.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 (54) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +18 -0
  4. package/README.en.md +167 -0
  5. package/README.md +167 -0
  6. package/TESTING.md +95 -0
  7. package/assets/en/preview-ball-detail.png +0 -0
  8. package/assets/en/preview-float.png +0 -0
  9. package/assets/en/preview-normal.png +0 -0
  10. package/assets/en/preview-side.png +0 -0
  11. package/assets/en/preview-split.png +0 -0
  12. package/assets/icon.svg +1 -0
  13. package/assets/preview-ball-detail.png +0 -0
  14. package/assets/preview-float.png +0 -0
  15. package/assets/preview-normal.png +0 -0
  16. package/assets/preview-side.png +0 -0
  17. package/assets/preview-split.png +0 -0
  18. package/cordis.patch.yml +5 -0
  19. package/lib/client.js +544 -0
  20. package/lib/index.js +5 -0
  21. package/package.json +79 -0
  22. package/src/client/attachments.ts +43 -0
  23. package/src/client/cm.ts +207 -0
  24. package/src/client/command.ts +78 -0
  25. package/src/client/components/AttachmentRail.tsx +241 -0
  26. package/src/client/components/CodinputBar.tsx +357 -0
  27. package/src/client/components/CodinputSurface.tsx +375 -0
  28. package/src/client/components/EditorPane.tsx +214 -0
  29. package/src/client/components/ExpandBall.tsx +122 -0
  30. package/src/client/components/MenuSurface.tsx +72 -0
  31. package/src/client/components/ModelMenu.tsx +234 -0
  32. package/src/client/components/PermissionMenu.tsx +257 -0
  33. package/src/client/components/PopupSelectView.tsx +140 -0
  34. package/src/client/components/SlashMenu.tsx +255 -0
  35. package/src/client/components/StatsRow.tsx +188 -0
  36. package/src/client/components/Toolbar.tsx +239 -0
  37. package/src/client/components/popup-filter.ts +13 -0
  38. package/src/client/components/useSnapshot.ts +18 -0
  39. package/src/client/diag.ts +58 -0
  40. package/src/client/host-types.ts +328 -0
  41. package/src/client/hotkey.ts +82 -0
  42. package/src/client/i18n.ts +312 -0
  43. package/src/client/icons.tsx +296 -0
  44. package/src/client/index.tsx +178 -0
  45. package/src/client/modeldir.ts +42 -0
  46. package/src/client/permission.ts +123 -0
  47. package/src/client/prefs.ts +123 -0
  48. package/src/client/services.ts +78 -0
  49. package/src/client/settings.tsx +236 -0
  50. package/src/client/sidebar.tsx +114 -0
  51. package/src/client/sideinput.ts +171 -0
  52. package/src/client/stats.ts +166 -0
  53. package/src/client/styles.ts +489 -0
  54. package/src/client/triggers.ts +84 -0
@@ -0,0 +1,357 @@
1
+ /**
2
+ * 接管条目:conversation.composer.bar(single,priority -1 遮蔽官方条目)。
3
+ * 卸载本条目即官方输入框原样恢复(槽位体系一等机制),草稿不丢。
4
+ * 另从 useChat 抽取轮次计时(模型用时 / TTFT),供数据行详情面板使用。
5
+ *
6
+ * 三态单实例(任一时刻恰好一个入口,且不与官方输入框共存):
7
+ * - `normal`:接管卡片在原输入位;
8
+ * - `float`:卡片 portal 悬浮——拖工具行上方空白区移动(窗口式),
9
+ * 拖边界/角落缩放;拖回底部输入区松手回 normal;
10
+ * - `side`:输入由右侧栏 Codinput 标签承载(本条目渲染 null)——侧栏
11
+ * 收起时**不让位**,入口仍是侧栏那一个(侧栏内隐藏),由右上角小球
12
+ * 唤出(见 components/ExpandBall)。
13
+ *
14
+ * side 由 sideinput 总线的承载态派生(标签 body 挂载即承载),优先级
15
+ * 高于持久化的 normal/float,因此不可能出现 side+float 并存或双实例。
16
+ */
17
+
18
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
19
+ import { createPortal } from 'react-dom';
20
+ import type { SessionMaybeStandard, SlotRegisterOptions } from '../host-types';
21
+ import { loadPrefs, savePrefs, subscribePrefs, type CodinputPrefs } from '../prefs';
22
+ import { useSidebarInputCarried } from '../sideinput';
23
+ import { CodinputBall } from './ExpandBall';
24
+ import { CodinputSurface } from './CodinputSurface';
25
+
26
+ /** ComposerBarOwnerProps 切面(官方 bar owner)。 */
27
+ interface BarOwner {
28
+ variant: 'hero' | 'composer';
29
+ blocked?: { readonly reason: string };
30
+ disabled?: boolean;
31
+ placeholder?: string;
32
+ }
33
+
34
+ /** 槽位作用域里的 chat 会话钩子(ui-session chat 源贡献)。 */
35
+ type BarProps = BarOwner & SessionMaybeStandard & { useChat?: <S>(sel: (s: unknown) => S) => S };
36
+
37
+ export interface ChatTimings {
38
+ /** 模型用时:全部步骤时长之和(毫秒)。 */
39
+ modelMs?: number;
40
+ /** 首 token 平均(TTFT):以首步骤时长近似(毫秒)。 */
41
+ ttftMs?: number;
42
+ }
43
+
44
+ /** 从 chat 树的节点缓存解析步骤计时(location.step.start/end)。 */
45
+ function computeTimings(valuesCache: unknown): ChatTimings {
46
+ if (!Array.isArray(valuesCache)) return {};
47
+ let modelMs = 0;
48
+ let ttftSum = 0;
49
+ let ttftCount = 0;
50
+ for (const node of valuesCache) {
51
+ const n = node as { kind?: string; location?: { step?: { start?: { time?: number }; end?: { time?: number } } } };
52
+ if (n?.kind !== 'assistant-step') continue;
53
+ const start = n.location?.step?.start?.time;
54
+ const end = n.location?.step?.end?.time;
55
+ if (start !== undefined && end !== undefined && end > start) {
56
+ modelMs += end - start;
57
+ ttftSum += end - start;
58
+ ttftCount += 1;
59
+ }
60
+ }
61
+ return { modelMs: modelMs > 0 ? modelMs : undefined, ttftMs: ttftCount > 0 ? ttftSum / ttftCount : undefined };
62
+ }
63
+
64
+ export function CodinputBar(props: BarProps): JSX.Element | null {
65
+ const { variant, blocked, disabled, placeholder, sessionId, useInput, inputActions, useProjection, useChat } = props;
66
+ const state = useInput((s) => s);
67
+ // 选择器只取数组引用(稳定),计时换算放在 useMemo 里做。
68
+ const valuesCache = useChat ? useChat((s: unknown) => (s as { nodes?: { valuesCache?: unknown } })?.nodes?.valuesCache) : undefined;
69
+ const chatTimings = useMemo<ChatTimings>(() => computeTimings(valuesCache), [valuesCache]);
70
+ const sideCarried = useSidebarInputCarried(sessionId);
71
+ const [mode, setMode] = useState<CodinputPrefs['mode']>(() => loadPrefs().mode);
72
+ useEffect(() => subscribePrefs(() => setMode(loadPrefs().mode)), []);
73
+ // side 优先:侧栏标签承载本会话输入时,主输入(含悬浮卡片)整体让位;
74
+ // 侧栏若已收起,则由标题栏下方的小球(portal)承担唯一入口。
75
+ if (sideCarried) return <CodinputBall sessionId={sessionId} />;
76
+ const floating = mode === 'float';
77
+ const surface = (
78
+ <CodinputSurface
79
+ sessionId={sessionId}
80
+ state={state}
81
+ actions={inputActions}
82
+ useProjection={useProjection}
83
+ placeholder={placeholder}
84
+ disabled={disabled === true || blocked !== undefined}
85
+ hero={variant === 'hero'}
86
+ chatTimings={chatTimings}
87
+ float={floating}
88
+ onDragZonePointerDown={beginFloatDrag}
89
+ />
90
+ );
91
+ if (floating) return <FloatingShell>{surface}</FloatingShell>;
92
+ return surface;
93
+ }
94
+
95
+ // ---------- 悬浮:跨重挂载的拖拽会话 + 悬浮壳(移动 + 八向缩放) ----------
96
+
97
+ const FLOAT_MARGIN = 12;
98
+ const DETACH_THRESHOLD = 6;
99
+ /** 悬浮默认宽(未手动缩放时);手动缩放后取 prefs.floatW。 */
100
+ const FLOAT_DEFAULT_W = 720;
101
+ /** 缩放下限/上限:与窗口一致的最小可用尺寸。 */
102
+ const FLOAT_MIN_W = 360;
103
+ const FLOAT_MIN_H = 140;
104
+ /** 缩放把手厚度(角落方块边长;边条厚度)。 */
105
+ const GRIP_EDGE = 6;
106
+ const GRIP_CORNER = 12;
107
+
108
+ function clamp(v: number, lo: number, hi: number): number {
109
+ return Math.min(hi, Math.max(lo, v));
110
+ }
111
+
112
+ /** 悬浮宽上限:视口内留边;小窗自适应。 */
113
+ function maxFloatW(): number {
114
+ return Math.max(FLOAT_MIN_W, window.innerWidth - FLOAT_MARGIN * 2);
115
+ }
116
+ function maxFloatH(): number {
117
+ return Math.max(FLOAT_MIN_H, window.innerHeight - FLOAT_MARGIN * 2);
118
+ }
119
+
120
+ /** 悬浮尺寸(视口坐标 + px)。`h === null` = 高度随内容。 */
121
+ interface FloatGeom {
122
+ x: number;
123
+ y: number;
124
+ w: number;
125
+ h: number | null;
126
+ }
127
+
128
+ function initialFloatGeom(): FloatGeom {
129
+ const prefs = loadPrefs();
130
+ return {
131
+ x: prefs.floatX ?? Math.round((window.innerWidth - Math.min(prefs.floatW ?? FLOAT_DEFAULT_W, maxFloatW())) / 2),
132
+ y: prefs.floatY ?? Math.round(window.innerHeight * 0.16),
133
+ w: prefs.floatW ?? FLOAT_DEFAULT_W,
134
+ h: prefs.floatH,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * 拖拽会话:从普通模式拖出时卡片会重挂载(inline → portal),拖拽状态
140
+ * 必须存模块级才能跨过那次重挂载继续跟手。位置通知走**模块级全局订阅**
141
+ * (不挂在单次会话对象上——悬浮壳先于后续会话挂载,会话级订阅会让
142
+ * 第二次以后的拖动收不到更新);松手按落点 docking 或记忆悬浮位置。
143
+ */
144
+ interface FloatDragSession {
145
+ grabDX: number;
146
+ grabDY: number;
147
+ /** 已脱出为悬浮(越过起拖阈值)。 */
148
+ detached: boolean;
149
+ /** 从悬浮态开始拖(此时无脱出判定,直接跟手)。 */
150
+ fromFloat: boolean;
151
+ lastX: number;
152
+ lastY: number;
153
+ }
154
+
155
+ let floatDrag: FloatDragSession | null = null;
156
+ const floatDragListeners = new Set<(x: number, y: number) => void>();
157
+
158
+ function notifyFloatDrag(x: number, y: number): void {
159
+ for (const fn of floatDragListeners) fn(x, y);
160
+ }
161
+
162
+ /** 底部输入区(松手落点在其中 → 回普通模式)。 */
163
+ function dockZone(): { left: number; right: number; top: number; bottom: number } {
164
+ const seat = document.querySelector('[data-composer-seat]');
165
+ const r = seat?.getBoundingClientRect();
166
+ const bottom = Math.min(window.innerHeight, (r?.bottom ?? window.innerHeight) + 120);
167
+ const top = Math.max(0, bottom - 300);
168
+ return {
169
+ left: (r?.left ?? 0) - 40,
170
+ right: (r?.right ?? window.innerWidth) + 40,
171
+ top,
172
+ bottom,
173
+ };
174
+ }
175
+
176
+ /** 拖动区 pointerdown 入口:普通模式一拖脱出悬浮,悬浮模式拖回底部回普通。 */
177
+ function beginFloatDrag(event: React.PointerEvent<HTMLDivElement>): void {
178
+ if (event.button !== 0) return;
179
+ const zone = event.currentTarget;
180
+ const card = zone.closest('[data-composer-card]');
181
+ const fromFloat = !!zone.closest('.dci-float');
182
+ const cardRect = card?.getBoundingClientRect();
183
+ const cardLeft = cardRect?.left ?? event.clientX;
184
+ const cardTop = cardRect?.top ?? event.clientY;
185
+ const width = cardRect?.width || Math.min(loadPrefs().floatW ?? FLOAT_DEFAULT_W, maxFloatW());
186
+ const clampX = (x: number): number => clamp(x, FLOAT_MARGIN, Math.max(FLOAT_MARGIN, window.innerWidth - width - FLOAT_MARGIN));
187
+ const clampY = (y: number): number => clamp(y, FLOAT_MARGIN, Math.max(FLOAT_MARGIN, window.innerHeight - 96));
188
+ const grabDX = clampX(event.clientX - cardLeft);
189
+ const grabDY = clampY(event.clientY - cardTop);
190
+ event.preventDefault();
191
+ const startX = event.clientX;
192
+ const startY = event.clientY;
193
+ const session: FloatDragSession = {
194
+ grabDX,
195
+ grabDY,
196
+ detached: fromFloat,
197
+ fromFloat,
198
+ lastX: clampX(event.clientX - grabDX),
199
+ lastY: clampY(event.clientY - grabDY),
200
+ };
201
+ const onMove = (e: PointerEvent): void => {
202
+ if (!floatDrag) return;
203
+ if (!session.detached) {
204
+ if (Math.abs(e.clientX - startX) < DETACH_THRESHOLD && Math.abs(e.clientY - startY) < DETACH_THRESHOLD) return;
205
+ session.detached = true;
206
+ // 脱出:悬浮卡片以当前抓取偏移出现在指针下(prefs 驱动重挂载)。
207
+ session.lastX = clampX(e.clientX - session.grabDX);
208
+ session.lastY = clampY(e.clientY - session.grabDY);
209
+ savePrefs({ mode: 'float', floatX: session.lastX, floatY: session.lastY });
210
+ notifyFloatDrag(session.lastX, session.lastY);
211
+ return;
212
+ }
213
+ session.lastX = clampX(e.clientX - session.grabDX);
214
+ session.lastY = clampY(e.clientY - session.grabDY);
215
+ notifyFloatDrag(session.lastX, session.lastY);
216
+ };
217
+ const onUp = (e: PointerEvent): void => {
218
+ window.removeEventListener('pointermove', onMove);
219
+ window.removeEventListener('pointerup', onUp);
220
+ window.removeEventListener('pointercancel', onUp);
221
+ floatDrag = null;
222
+ if (!session.detached) return; // 原地轻点:无事发生
223
+ const z = dockZone();
224
+ if (e.clientX >= z.left && e.clientX <= z.right && e.clientY >= z.top && e.clientY <= z.bottom) {
225
+ savePrefs({ mode: 'normal' }); // 拖回底部输入区:回普通模式(悬浮位置/尺寸保留)
226
+ return;
227
+ }
228
+ savePrefs({ floatX: session.lastX, floatY: session.lastY });
229
+ };
230
+ floatDrag = session;
231
+ window.addEventListener('pointermove', onMove);
232
+ window.addEventListener('pointerup', onUp);
233
+ window.addEventListener('pointercancel', onUp);
234
+ }
235
+
236
+ /** 缩放方向:边(n/s/e/w)与角落(组合)。 */
237
+ type GripDir = 'n' | 's' | 'e' | 'w' | 'nw' | 'ne' | 'sw' | 'se';
238
+
239
+ const GRIP_DIRS: readonly GripDir[] = ['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'];
240
+
241
+ /**
242
+ * 悬浮壳:portal 到 body 的固定定位卡片容器。挂载时若拖拽会话进行中
243
+ * (刚从普通模式脱出)则订阅会话位置实时跟手;否则用 prefs 记忆的位置
244
+ * 与尺寸。八向把手 1:1 跟手缩放,缩放期间不动内容元素引用(React 对
245
+ * 子树 bail-out,只有外壳尺寸在变)。
246
+ */
247
+ function FloatingShell(props: { children: JSX.Element }): JSX.Element {
248
+ const { children } = props;
249
+ const wrapRef = useRef<HTMLDivElement | null>(null);
250
+ const [geom, setGeom] = useState<FloatGeom>(() => {
251
+ if (floatDrag) {
252
+ const base = initialFloatGeom();
253
+ return { ...base, x: floatDrag.lastX, y: floatDrag.lastY };
254
+ }
255
+ return initialFloatGeom();
256
+ });
257
+ /** 最新几何:缩放/移动的实时读数(不进 React 状态,避免闭包过期)。 */
258
+ const geomRef = useRef(geom);
259
+ geomRef.current = geom;
260
+
261
+ // 拖拽位置走模块级全局订阅:悬浮壳可能先于后续拖拽会话挂载,稳定
262
+ // 订阅保证任意一次会话的移动都能跟手(位置更新只碰包裹层,children
263
+ // 元素引用不变,React 对子树 bail-out,不重渲染表面)。
264
+ useLayoutEffect(() => {
265
+ const onMove = (x: number, y: number): void => setGeom((prev) => ({ ...prev, x, y }));
266
+ floatDragListeners.add(onMove);
267
+ return () => {
268
+ floatDragListeners.delete(onMove);
269
+ };
270
+ }, []);
271
+
272
+ /** 窗口式缩放:按住边/角落 1:1 跟手;对边为锚点,窗口不越出视口、不小于下限。 */
273
+ const beginResize = (dir: GripDir) =>
274
+ (event: React.PointerEvent<HTMLDivElement>): void => {
275
+ if (event.button !== 0) return;
276
+ event.preventDefault();
277
+ event.stopPropagation();
278
+ const start = geomRef.current;
279
+ const shell = wrapRef.current;
280
+ const rect = shell?.getBoundingClientRect();
281
+ const startW = rect?.width ?? start.w;
282
+ const startH = rect?.height ?? start.h ?? FLOAT_MIN_H;
283
+ const startX = start.x;
284
+ const startY = start.y;
285
+ const right = startX + startW;
286
+ const bottom = startY + startH;
287
+ const pointerX = event.clientX;
288
+ const pointerY = event.clientY;
289
+ const onMove = (e: PointerEvent): void => {
290
+ const dx = e.clientX - pointerX;
291
+ const dy = e.clientY - pointerY;
292
+ let w = startW;
293
+ let h = startH;
294
+ let x = startX;
295
+ let y = startY;
296
+ if (dir.includes('e')) w = clamp(startW + dx, FLOAT_MIN_W, Math.max(FLOAT_MIN_W, window.innerWidth - FLOAT_MARGIN - startX));
297
+ if (dir.includes('w')) {
298
+ x = clamp(startX + dx, FLOAT_MARGIN, right - FLOAT_MIN_W);
299
+ w = right - x;
300
+ }
301
+ if (dir.includes('s')) h = clamp(startH + dy, FLOAT_MIN_H, Math.max(FLOAT_MIN_H, window.innerHeight - FLOAT_MARGIN - startY));
302
+ if (dir.includes('n')) {
303
+ y = clamp(startY + dy, FLOAT_MARGIN, bottom - FLOAT_MIN_H);
304
+ h = bottom - y;
305
+ }
306
+ setGeom({ x, y, w: Math.round(w), h: Math.round(h) });
307
+ };
308
+ const onUp = (): void => {
309
+ window.removeEventListener('pointermove', onMove);
310
+ window.removeEventListener('pointerup', onUp);
311
+ window.removeEventListener('pointercancel', onUp);
312
+ const g = geomRef.current;
313
+ savePrefs({ floatX: g.x, floatY: g.y, floatW: g.w, floatH: g.h });
314
+ };
315
+ try {
316
+ event.currentTarget.setPointerCapture(event.pointerId);
317
+ } catch {
318
+ /* 合成事件无活动指针:window 监听兜底 */
319
+ }
320
+ window.addEventListener('pointermove', onMove);
321
+ window.addEventListener('pointerup', onUp);
322
+ window.addEventListener('pointercancel', onUp);
323
+ };
324
+
325
+ // 渲染时夹取:窗口缩小后悬浮卡片不被推出视口(纵向沿用移动手势的
326
+ // 「底部至少留 96px 在视口内」约定,不改动已验收的拖动行为)。
327
+ const w = Math.min(geom.w, maxFloatW());
328
+ const h = geom.h === null ? null : Math.min(geom.h, maxFloatH());
329
+ const left = clamp(geom.x, FLOAT_MARGIN, Math.max(FLOAT_MARGIN, window.innerWidth - w - FLOAT_MARGIN));
330
+ const top = clamp(geom.y, FLOAT_MARGIN, Math.max(FLOAT_MARGIN, window.innerHeight - 96));
331
+
332
+ return createPortal(
333
+ <div
334
+ className="dci-float"
335
+ ref={wrapRef}
336
+ data-sized={h !== null ? 'true' : undefined}
337
+ style={{ left, top, width: w, height: h ?? undefined, ['--dci-grip-edge' as string]: `${GRIP_EDGE}px`, ['--dci-grip-corner' as string]: `${GRIP_CORNER}px` }}
338
+ >
339
+ {children}
340
+ {GRIP_DIRS.map((dir) => (
341
+ <div key={dir} className="dci-float-grip" data-dir={dir} onPointerDown={beginResize(dir)} />
342
+ ))}
343
+ </div>,
344
+ document.body,
345
+ );
346
+ }
347
+
348
+ /** 注册选项(遮蔽条目必须 priority -1:升序最低者渲染)。 */
349
+ export function barRegistration(): { options: SlotRegisterOptions } {
350
+ return {
351
+ options: {
352
+ name: 'conversation.composer.bar',
353
+ priority: -1,
354
+ registrant: 'dsh-codinput',
355
+ },
356
+ };
357
+ }