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,171 @@
1
+ /**
2
+ * 侧栏输入承载总线:侧栏 Codinput 标签 body **挂载**即声明「本会话的输入由
3
+ * 侧栏承载」(侧栏形态 = 三态里的 `side`),主输入遮蔽条目(conversation.
4
+ * composer.bar)据此渲染 null,保证屏幕上只有一个输入组件。
5
+ *
6
+ * 关键:可见性不再参与承载判定。侧栏收起是 CSS 隐藏(body 仍挂载),若按
7
+ * 可见性让位,收起侧栏会唤回普通模式/官方输入框——产品上不允许(收起只
8
+ * 是「输入入口被收进侧栏且不可见」,入口仍是侧栏那一个,经右上角小球唤出)。
9
+ * `visible` 与 `expanded` 仍逐拍上报,供小球判断「侧栏已收起」用。
10
+ *
11
+ * 总线是纯模块态:侧栏与主输入分属两棵 React 树,唯一的通信面就是这里。
12
+ */
13
+
14
+ import { useEffect, useState } from 'react';
15
+
16
+ export interface SidebarCarryState {
17
+ /** 官方可见判定(useTabInfo().tab.visible):展开且为激活标签,或浮动面板。 */
18
+ visible: boolean;
19
+ /** 右侧栏是否展开(useTabInfo().sidebar.expanded)。 */
20
+ expanded: boolean;
21
+ }
22
+
23
+ export interface SidebarCarry {
24
+ /** 本次承载的令牌(同会话多 body 时,最早的持令牌者渲染编辑面)。 */
25
+ readonly token: number;
26
+ /** 可见性/展开态变化时更新(同一 body 生命周期内)。 */
27
+ set(state: SidebarCarryState): void;
28
+ /** body 卸载时释放。 */
29
+ release(): void;
30
+ }
31
+
32
+ const claims = new Map<number, SidebarCarryState & { sessionId: string }>();
33
+ const listeners = new Set<() => void>();
34
+ let nextToken = 1;
35
+ /** 快照:被侧栏承载的会话(挂载即承载)→ 可见性/展开态 + 编辑面持有者令牌。 */
36
+ let snapshot: ReadonlyMap<string, SidebarCarryState & { owner: number }> = new Map();
37
+
38
+ function sameState(a: SidebarCarryState, b: SidebarCarryState): boolean {
39
+ return a.visible === b.visible && a.expanded === b.expanded;
40
+ }
41
+
42
+ function sameSnapshot(next: Map<string, SidebarCarryState & { owner: number }>): boolean {
43
+ if (next.size !== snapshot.size) return false;
44
+ for (const [id, state] of next) {
45
+ const prev = snapshot.get(id);
46
+ if (!prev || !sameState(prev, state) || prev.owner !== state.owner) return false;
47
+ }
48
+ return true;
49
+ }
50
+
51
+ function recompute(): void {
52
+ const next = new Map<string, SidebarCarryState & { owner: number }>();
53
+ for (const [token, claim] of claims) {
54
+ const prev = next.get(claim.sessionId);
55
+ // 同一会话多 body(分栏里复制出第二个 Codinput 标签)时取「最可见」的一份,
56
+ // 编辑面归属最早声明者(owner),其余 body 只出提示,杜绝双编辑面。
57
+ if (!prev) {
58
+ next.set(claim.sessionId, { visible: claim.visible, expanded: claim.expanded, owner: token });
59
+ } else if (!prev.visible && claim.visible) {
60
+ next.set(claim.sessionId, { ...prev, visible: true, expanded: claim.expanded });
61
+ }
62
+ }
63
+ if (sameSnapshot(next)) return;
64
+ snapshot = next;
65
+ for (const fn of listeners) fn();
66
+ }
67
+
68
+ /** 侧栏 body 声明承载本会话输入;返回句柄,卸载时 release。 */
69
+ export function claimSidebarInput(sessionId: string, state: SidebarCarryState): SidebarCarry {
70
+ const token = nextToken++;
71
+ claims.set(token, { sessionId, ...state });
72
+ recompute();
73
+ let released = false;
74
+ return {
75
+ token,
76
+ set(next: SidebarCarryState): void {
77
+ const claim = claims.get(token);
78
+ if (claim && !sameState(claim, next)) {
79
+ claims.set(token, { sessionId: claim.sessionId, ...next });
80
+ recompute();
81
+ }
82
+ },
83
+ release(): void {
84
+ if (released) return;
85
+ released = true;
86
+ if (claims.delete(token)) recompute();
87
+ },
88
+ };
89
+ }
90
+
91
+ /** 本会话的输入是否由侧栏承载(主输入应渲染 null;即形态 = side)。 */
92
+ export function isSidebarInputCarried(sessionId: string | undefined): boolean {
93
+ return sessionId !== undefined && snapshot.has(sessionId);
94
+ }
95
+
96
+ /** 任一会话被侧栏承载(主遮蔽条目需保持挂载以执行隐藏)。 */
97
+ export function isAnySidebarInputCarried(): boolean {
98
+ return snapshot.size > 0;
99
+ }
100
+
101
+ /** 本会话的侧栏承载态快照(未承载时 undefined)。 */
102
+ export function sidebarCarryState(sessionId: string | undefined): SidebarCarryState | undefined {
103
+ return sessionId === undefined ? undefined : snapshot.get(sessionId);
104
+ }
105
+
106
+ /**
107
+ * 编辑面归属:同会话被多个 body 承载(分栏里复制出第二个 Codinput 标签)时,
108
+ * 只有最早声明者渲染编辑面。`token === 0`(尚未声明)一律返回 true——避免
109
+ * 首帧把还没拿到令牌的自己判成非归属者。
110
+ */
111
+ export function isSidebarCarryOwner(sessionId: string | undefined, token: number): boolean {
112
+ if (token === 0 || sessionId === undefined) return true;
113
+ return snapshot.get(sessionId)?.owner === token;
114
+ }
115
+
116
+ /** 归属订阅(body 用):令牌变化、其他 body 挂载/卸载都会重渲染。 */
117
+ export function useSidebarCarryOwner(sessionId: string | undefined, token: number): boolean {
118
+ const [owner, setOwner] = useState(() => isSidebarCarryOwner(sessionId, token));
119
+ useEffect(() => {
120
+ const sync = (): void => {
121
+ const now = isSidebarCarryOwner(sessionId, token);
122
+ setOwner((prev) => (prev === now ? prev : now));
123
+ };
124
+ sync();
125
+ return subscribeSidebarInput(sync);
126
+ }, [sessionId, token]);
127
+ return owner;
128
+ }
129
+
130
+ export function subscribeSidebarInput(fn: () => void): () => void {
131
+ listeners.add(fn);
132
+ return () => {
133
+ listeners.delete(fn);
134
+ };
135
+ }
136
+
137
+ /** 主输入组件订阅:本会话输入是否已由侧栏承载。 */
138
+ export function useSidebarInputCarried(sessionId: string | undefined): boolean {
139
+ const [carried, setCarried] = useState(() => isSidebarInputCarried(sessionId));
140
+ useEffect(() => {
141
+ const sync = (): void => {
142
+ const now = isSidebarInputCarried(sessionId);
143
+ setCarried((prev) => (prev === now ? prev : now));
144
+ };
145
+ sync();
146
+ return subscribeSidebarInput(sync);
147
+ }, [sessionId]);
148
+ return carried;
149
+ }
150
+
151
+ /**
152
+ * 侧栏承载态订阅(小球用):`carried` = 本会话输入在侧栏里,
153
+ * `collapsed` = 侧栏已收起(承载但不可见 → 输入入口需要小球唤出)。
154
+ */
155
+ export function useSidebarCarry(sessionId: string | undefined): { carried: boolean; collapsed: boolean } {
156
+ const read = (): { carried: boolean; collapsed: boolean } => {
157
+ const state = sidebarCarryState(sessionId);
158
+ return { carried: state !== undefined, collapsed: state !== undefined && state.expanded === false };
159
+ };
160
+ const [value, setValue] = useState(read);
161
+ useEffect(() => {
162
+ const sync = (): void => {
163
+ const next = read();
164
+ setValue((prev) => (prev.carried === next.carried && prev.collapsed === next.collapsed ? prev : next));
165
+ };
166
+ sync();
167
+ return subscribeSidebarInput(sync);
168
+ // eslint-disable-next-line react-hooks/exhaustive-deps
169
+ }, [sessionId]);
170
+ return value;
171
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 数据行数据源(卡片下方):会话统计投影探测 + 归一化。
3
+ * 官方投影键(实测确认):tokenUsage(cacheRead/cacheWrite/output/
4
+ * uncachedInput)、contextPressure(contextWindow/pressureTokens/
5
+ * projectedTokens);轮/步按别名键探测。tok/s 为输出令牌的实时采样速率。
6
+ * 注意:useProjection 是真实 hook,键探测必须是固定次数的顶层调用。
7
+ */
8
+
9
+ import type { UseProjection } from './host-types';
10
+
11
+ const PROJECTION_KEYS = [
12
+ 'tokenUsage',
13
+ 'contextPressure',
14
+ 'sessionUsage',
15
+ 'usage',
16
+ 'sessionStats',
17
+ ] as const;
18
+
19
+ export interface SessionStats {
20
+ turns?: number;
21
+ steps?: number;
22
+ /** 实时输出速率(tok/s),运行期间采样更新,空闲时保留最后一次。 */
23
+ tokensPerSecond?: number;
24
+ totalTokens?: number;
25
+ cacheHitPct?: number;
26
+ /** 上下文占用百分比(pressureTokens / contextWindow)。 */
27
+ contextPct?: number;
28
+ /** 会话统计面板:模型用时(流式活跃累计毫秒)与首 token 延迟。 */
29
+ modelMs?: number;
30
+ ttftMs?: number;
31
+ /** Token 用量面板:原始分段。 */
32
+ uncachedInputTokens?: number;
33
+ cacheReadTokens?: number;
34
+ outputTokens?: number;
35
+ }
36
+
37
+ interface TokenUsage {
38
+ cacheReadTokens?: number;
39
+ cacheWriteTokens?: number;
40
+ outputTokens?: number;
41
+ uncachedInputTokens?: number;
42
+ }
43
+
44
+ interface ContextPressure {
45
+ contextWindow?: number;
46
+ pressureTokens?: number;
47
+ projectedTokens?: number;
48
+ }
49
+
50
+ function num(value: unknown): number | undefined {
51
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
52
+ }
53
+
54
+ function readTokenUsage(raw: unknown): TokenUsage | undefined {
55
+ if (!raw || typeof raw !== 'object') return undefined;
56
+ const r = raw as Record<string, unknown>;
57
+ const usage: TokenUsage = {
58
+ cacheReadTokens: num(r['cacheReadTokens']),
59
+ cacheWriteTokens: num(r['cacheWriteTokens']),
60
+ outputTokens: num(r['outputTokens']),
61
+ uncachedInputTokens: num(r['uncachedInputTokens']),
62
+ };
63
+ return Object.values(usage).some((v) => v !== undefined) ? usage : undefined;
64
+ }
65
+
66
+ function readContextPressure(raw: unknown): ContextPressure | undefined {
67
+ if (!raw || typeof raw !== 'object') return undefined;
68
+ const r = raw as Record<string, unknown>;
69
+ const cp: ContextPressure = {
70
+ contextWindow: num(r['contextWindow']),
71
+ pressureTokens: num(r['pressureTokens']),
72
+ projectedTokens: num(r['projectedTokens']),
73
+ };
74
+ return cp.contextWindow !== undefined && cp.pressureTokens !== undefined ? cp : undefined;
75
+ }
76
+
77
+ function readTurnsSteps(raw: unknown): { turns?: number; steps?: number } {
78
+ if (!raw || typeof raw !== 'object') return {};
79
+ const r = raw as Record<string, unknown>;
80
+ const turns = num(r['turns']) ?? num(r['turnCount']);
81
+ const steps = num(r['steps']) ?? num(r['stepCount']);
82
+ return { turns, steps };
83
+ }
84
+
85
+ /** 实时速率与用时采样:模块级(按 sessionId 记录上次输出令牌数与时刻)。 */
86
+ const rateSamples = new Map<string, { tokens: number; time: number; rate?: number; modelMs: number }>();
87
+
88
+ export function sampleTokensPerSecond(
89
+ sessionId: string | undefined,
90
+ outputTokens: number | undefined,
91
+ ): { rate?: number; modelMs?: number; ttftMs?: number } {
92
+ if (!sessionId || outputTokens === undefined) return {};
93
+ const now = Date.now();
94
+ const last = rateSamples.get(sessionId);
95
+ let rate: number | undefined = last?.rate;
96
+ let modelMs = last?.modelMs ?? 0;
97
+ let ttftMs: number | undefined;
98
+ if (!last) {
99
+ // 首个样本:无从计算速率;当前值即初始输出。
100
+ rateSamples.set(sessionId, { tokens: outputTokens, time: now, modelMs: 0 });
101
+ return {};
102
+ }
103
+ const dt = now - last.time;
104
+ if (outputTokens > last.tokens && dt > 0) {
105
+ rate = ((outputTokens - last.tokens) / dt) * 1000;
106
+ modelMs += dt;
107
+ // 空闲较久后恢复增长:这段等待近似 TTFT。
108
+ if (dt > 600) ttftMs = dt;
109
+ }
110
+ rateSamples.set(sessionId, { tokens: outputTokens, time: now, rate, modelMs });
111
+ return { rate, modelMs, ttftMs };
112
+ }
113
+
114
+ /** 每个键一个固定位置的 hook 调用——跨渲染的调用次数与顺序恒定。 */
115
+ export function useSessionStats(
116
+ useProjection: UseProjection | undefined,
117
+ sessionId: string | undefined,
118
+ ): SessionStats | null {
119
+ const k0 = useProjection ? useProjection(PROJECTION_KEYS[0]) : undefined;
120
+ const k1 = useProjection ? useProjection(PROJECTION_KEYS[1]) : undefined;
121
+ const k2 = useProjection ? useProjection(PROJECTION_KEYS[2]) : undefined;
122
+ const k3 = useProjection ? useProjection(PROJECTION_KEYS[3]) : undefined;
123
+ const k4 = useProjection ? useProjection(PROJECTION_KEYS[4]) : undefined;
124
+
125
+ const usage = readTokenUsage(k0);
126
+ const pressure = readContextPressure(k1);
127
+ const { turns, steps } = readTurnsSteps(k2) ?? {};
128
+ const alt = readTurnsSteps(k3) ?? {};
129
+ const alt2 = readTurnsSteps(k4) ?? {};
130
+
131
+ const stats: SessionStats = {};
132
+ if (turns !== undefined || steps !== undefined) Object.assign(stats, { turns, steps });
133
+ else if (alt.turns !== undefined || alt.steps !== undefined) Object.assign(stats, alt);
134
+ else if (alt2.turns !== undefined || alt2.steps !== undefined) Object.assign(stats, alt2);
135
+ if (usage) {
136
+ const total =
137
+ (usage.cacheReadTokens ?? 0) +
138
+ (usage.cacheWriteTokens ?? 0) +
139
+ (usage.outputTokens ?? 0) +
140
+ (usage.uncachedInputTokens ?? 0);
141
+ if (total > 0) {
142
+ stats.totalTokens = total;
143
+ stats.uncachedInputTokens = usage.uncachedInputTokens;
144
+ stats.cacheReadTokens = usage.cacheReadTokens;
145
+ stats.outputTokens = usage.outputTokens;
146
+ }
147
+ const inputTotal = (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) + (usage.uncachedInputTokens ?? 0);
148
+ if (inputTotal > 0) stats.cacheHitPct = ((usage.cacheReadTokens ?? 0) / inputTotal) * 100;
149
+ const sample = sampleTokensPerSecond(sessionId, usage.outputTokens);
150
+ if (sample.rate !== undefined && sample.rate > 0) stats.tokensPerSecond = sample.rate;
151
+ if (sample.modelMs !== undefined && sample.modelMs > 0) stats.modelMs = sample.modelMs;
152
+ if (sample.ttftMs !== undefined && sample.ttftMs > 0) stats.ttftMs = sample.ttftMs;
153
+ }
154
+ const window_ = pressure?.contextWindow;
155
+ const pressured = pressure?.pressureTokens;
156
+ if (window_ !== undefined && window_ > 0 && pressured !== undefined) {
157
+ stats.contextPct = (pressured / window_) * 100;
158
+ }
159
+ return Object.keys(stats).length > 0 ? stats : null;
160
+ }
161
+
162
+ export function formatTokens(tokens: number): string {
163
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M tok`;
164
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens % 1_000 === 0 ? 0 : 1)}K tok`;
165
+ return `${tokens} tok`;
166
+ }