dsh-vscode-mode 0.8.0 → 0.9.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.
@@ -264,6 +264,138 @@ export interface CtxLike {
264
264
  get: (name: string) => unknown
265
265
  }
266
266
 
267
+ /** 新建会话结果(ok=false 供调用方降级回当前对话注入)。 */
268
+ export interface DraftSessionResult {
269
+ ok: boolean
270
+ /** 新会话 id(ok=true 时存在;投递命令按它内嵌)。 */
271
+ id?: string
272
+ }
273
+
274
+ /** apiproxy wire 端点(POST /api/<method>;浏览器同源 fetch 自带 cookie 鉴权;实证 401=未鉴权而非 404)。 */
275
+ const SESSION_CREATE_PATH = '/api/session/create'
276
+ /** apiproxy wire 端点:发送任务(session/prompt;点击即发送方案)。 */
277
+ const SESSION_PROMPT_PATH = '/api/session/prompt'
278
+
279
+ /** mint RPC 请求 id(RpcId 品牌类型运行时即普通字符串)。 */
280
+ function mintRpcId(): string {
281
+ try {
282
+ return globalThis.crypto?.randomUUID?.() ?? ('rpc-' + Date.now())
283
+ } catch {
284
+ return 'rpc-' + Date.now()
285
+ }
286
+ }
287
+
288
+ /**
289
+ * RPC 返回解析(照官方 dsh-client-runtime 调用形态:`const { result } = await api.sessions.create(...)`)。
290
+ * 官方 api.sessions 面返回**信封** `{ rpcId, result: RpcResult }`——须解 .result;
291
+ * 兼容裸 RpcResult 形态(个别补丁面直接回 {ok, value})。
292
+ * @author ddj 2026年09月23号
293
+ * @param res 信封或裸 RpcResult
294
+ * @returns 归一后的 { ok, value }
295
+ */
296
+ function rpcOk(res: unknown): { ok: boolean; value?: unknown } {
297
+ const envelope = (res as { result?: unknown } | null)?.result
298
+ const r = (envelope ?? res) as { ok?: boolean; value?: unknown } | null
299
+ return { ok: r?.ok === true, value: r?.value }
300
+ }
301
+
302
+ /**
303
+ * wire 直调 session/create(主路径):ClientRequest 信封 → ServerResponse.result(RpcResult 形态)。
304
+ * 契约以当前安装版 dsh-host-apiproxy 为准(api/sessions.d.ts「Method signatures are the
305
+ * source of truth」):`create({cwd}) → {sessionId}`;信封 = `{type:'client-request', rpcId,
306
+ * method, payload}` → `{result: {ok:true, value:{sessionId}} | {ok:false, error}}`。
307
+ * @author ddj 2026年09月23号
308
+ * @param cwd 新会话工作区目录
309
+ * @returns 新会话 id(失败空串)
310
+ */
311
+ async function apiSessionCreate(cwd: string | undefined): Promise<string> {
312
+ const res = await fetch(SESSION_CREATE_PATH, {
313
+ method: 'POST',
314
+ headers: { 'content-type': 'application/json' },
315
+ body: JSON.stringify({
316
+ type: 'client-request',
317
+ rpcId: mintRpcId(),
318
+ method: 'session/create',
319
+ payload: cwd ? { cwd } : {},
320
+ }),
321
+ })
322
+ const json = await res.json().catch(() => null) as { result?: { ok?: boolean; value?: { sessionId?: unknown } } } | null
323
+ const id = json?.result?.value?.sessionId
324
+ return json?.result?.ok === true && typeof id === 'string' ? id : ''
325
+ }
326
+
327
+ /**
328
+ * wire 直发 session/prompt(点击即发送方案):任务文本投给目标会话 agent。
329
+ * 契约 = 当前安装树 SessionsApi.prompt:`{sessionId, mode:'queue', content:[{type:'text',text}]}`。
330
+ * 发送成功会话即有内容 → 不再被 blank 隐藏(列表可见)。
331
+ * @author ddj 2026年09月23号
332
+ * @param sessionId 目标会话 id(新会话)
333
+ * @param text 任务文本
334
+ * @returns 是否受理
335
+ */
336
+ export async function apiSessionPrompt(sessionId: string, text: string): Promise<boolean> {
337
+ try {
338
+ const res = await fetch(SESSION_PROMPT_PATH, {
339
+ method: 'POST',
340
+ headers: { 'content-type': 'application/json' },
341
+ body: JSON.stringify({
342
+ type: 'client-request',
343
+ rpcId: mintRpcId(),
344
+ method: 'session/prompt',
345
+ payload: { sessionId, mode: 'queue', content: [{ type: 'text', text }] },
346
+ }),
347
+ })
348
+ const json = await res.json().catch(() => null) as { result?: { ok?: boolean } } | null
349
+ return json?.result?.ok === true
350
+ } catch {
351
+ return false
352
+ }
353
+ }
354
+
355
+ /**
356
+ * 新建独立会话承载旁路任务(02-deep-session-prompt:深度分析不污染当前对话)。
357
+ * 主路径 = apiproxy wire 直调(不依赖 service 面形态猜测);回落 remote.session.create
358
+ * 与旧 sessions.create 两代 service 面(宽松解析)。任一步不符返回 ok:false,调用方降级。
359
+ * @author ddj 2026年09月23号
360
+ * @param ctx 客户端服务上下文(remote.session + sessions,仅回落用)
361
+ * @param cwd 新会话工作区目录(与当前会话同工作区)
362
+ * @returns 新会话结果(含 id)
363
+ */
364
+ export async function newDraftSession(ctx: CtxLike, cwd: string | undefined): Promise<DraftSessionResult> {
365
+ // 主路径:wire 直调
366
+ try {
367
+ const id = await apiSessionCreate(cwd)
368
+ if (id) return { ok: true, id }
369
+ } catch { /* wire 不可用:回落 service 面 */ }
370
+ try {
371
+ // 回落 1:remote.session service 面(官方返回信封 {rpcId, result},见 rpcOk;实参双形态兼容)
372
+ const remote = ctx.get('remote.session') as {
373
+ create?: (req: unknown) => Promise<unknown>
374
+ } | undefined
375
+ if (typeof remote?.create === 'function') {
376
+ const payload = cwd ? { cwd } : {}
377
+ for (const arg of [payload, { rpcId: mintRpcId(), payload }]) {
378
+ const r = rpcOk(await remote.create(arg))
379
+ const id = r.value as { sessionId?: unknown } | undefined
380
+ if (r.ok && typeof id?.sessionId === 'string') return { ok: true, id: id.sessionId }
381
+ }
382
+ }
383
+ // 回落 2:旧版 client store 形态(id / {id} / {sessionId} / {session:{id}} 四解)
384
+ const sessions = ctx.get('sessions') as { create?: (opts?: { cwd?: string }) => unknown } | undefined
385
+ if (typeof sessions?.create === 'function') {
386
+ const created = await sessions.create(cwd ? { cwd } : undefined)
387
+ const id = typeof created === 'string' && created ? created
388
+ : typeof (created as { id?: unknown } | null)?.id === 'string' ? (created as { id: string }).id
389
+ : typeof (created as { sessionId?: unknown } | null)?.sessionId === 'string' ? (created as { sessionId: string }).sessionId
390
+ : typeof (created as { session?: { id?: unknown } } | null)?.session?.id === 'string'
391
+ ? (created as { session: { id: string } }).session.id
392
+ : ''
393
+ if (id) return { ok: true, id }
394
+ }
395
+ } catch { /* 全部失败:调用方降级 */ }
396
+ return { ok: false }
397
+ }
398
+
267
399
  /** 注入器产物:给 EditorView 用的动作集合。 */
268
400
  export interface AddToConversation {
269
401
  /** 追加文件/文件夹引用 chip(@path [Lstart-end]);忙态自动降级纯文本。 */
@@ -273,6 +405,12 @@ export interface AddToConversation {
273
405
  range?: RefRange,
274
406
  appearance?: RefAppearance,
275
407
  ): Promise<AddOutcome>
408
+ /** 追加纯文本(AI 深度分析任务等长文本注入;插到当前光标,失败不整篇重建)。 */
409
+ appendText(sessionId: string | undefined, text: string): Promise<AddOutcome>
410
+ /** 新建独立会话并切换(旁路任务隔离;失败 ok:false 供降级回当前对话)。 */
411
+ startDraftSession(cwd?: string): Promise<DraftSessionResult>
412
+ /** 发送任务给目标会话 agent(点击即发送;失败 false 供降级)。 */
413
+ sendTask(sessionId: string | undefined, text: string): Promise<boolean>
276
414
  }
277
415
 
278
416
  /**
@@ -392,5 +530,69 @@ export function createAddToConversation(ctx: CtxLike): AddToConversation {
392
530
  return 'failed'
393
531
  }
394
532
 
395
- return { appendReference }
533
+ /**
534
+ * 追加纯文本(AI 深度分析任务注入):走官方 plain-text 通道插到当前光标。
535
+ * 失败语义与 appendReference 一致:宁可不写也不整篇重建(保护既有 chip)。
536
+ * @author ddj 2026年09月23号
537
+ * @param sessionId 会话 id
538
+ * @param text 待注入文本
539
+ * @returns 注入结果(ok / unavailable / failed)
540
+ */
541
+ const appendText: AddToConversation['appendText'] = async (sessionId, text) => {
542
+ const input = inputFor(ctx, sessionId)
543
+ if (!input) return 'unavailable'
544
+ const cur = draftCursor(input)
545
+ if (!cur) return 'unavailable'
546
+ const at = insertOffsetOf(caretOf(input), detectEndOf(cur))
547
+ const span: TokenSpanLike = { start: at, end: at, draftRev: cur.draftRev }
548
+ return safeInsertText(input, text, span) ? 'ok' : 'failed'
549
+ }
550
+
551
+ /**
552
+ * 新建独立会话(02-deep-session-prompt:旁路任务不污染当前对话)。
553
+ * 闭包持有真服务 ctx(面板 SidebarCtx 无 .get,会话操作必须经此门面);
554
+ * 创建后尽力 `sessions.open(id)` 切换视图(缺失不致命,草稿仍可写入)。
555
+ * @author ddj 2026年09月23号
556
+ * @param cwd 新会话工作区目录(与当前会话同工作区)
557
+ * @returns 新会话结果(失败供降级)
558
+ */
559
+ const startDraftSession: AddToConversation['startDraftSession'] = async (cwd) => {
560
+ const result = await newDraftSession(ctx, cwd)
561
+ if (result.ok && result.id) {
562
+ // 尽力切换到新会话(缺失/异常不影响结果——仅视图不切换)
563
+ try {
564
+ const sessions = ctx.get('sessions') as { open?: (id: string) => void } | undefined
565
+ sessions?.open?.(result.id)
566
+ } catch { /* 切换失败仅视图停留 */ }
567
+ }
568
+ return result
569
+ }
570
+
571
+ /**
572
+ * 发送任务给目标会话 agent(点击即发送方案:任务落地会话即非 blank,列表可见)。
573
+ * 主路径 wire 直发 session/prompt;回落 remote.session.prompt service 面。
574
+ * @author ddj 2026年09月23号
575
+ * @param sessionId 目标会话 id
576
+ * @param text 任务文本
577
+ * @returns 是否受理
578
+ */
579
+ const sendTask: AddToConversation['sendTask'] = async (sessionId, text) => {
580
+ if (!sessionId) return false
581
+ try {
582
+ if (await apiSessionPrompt(sessionId, text)) return true
583
+ } catch { /* 回落 service 面 */ }
584
+ try {
585
+ const remote = ctx.get('remote.session') as { prompt?: (req: unknown) => Promise<unknown> } | undefined
586
+ if (typeof remote?.prompt === 'function') {
587
+ // 官方 api.sessions.prompt(request) 返回信封(rpcOk 解 .result);实参双形态兼容
588
+ const payload = { sessionId, mode: 'queue', content: [{ type: 'text', text }] }
589
+ for (const arg of [payload, { rpcId: mintRpcId(), payload }]) {
590
+ if (rpcOk(await remote.prompt(arg)).ok) return true
591
+ }
592
+ }
593
+ } catch { /* 全失败 */ }
594
+ return false
595
+ }
596
+
597
+ return { appendReference, appendText, startDraftSession, sendTask }
396
598
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * dsh-vscode-mode client — 跨挂载/跨热重载的待跳转交接(pending nav handoff)。
3
+ *
4
+ * 为什么必须落 window(第三次事故根因,2026-09-23 实测取证):
5
+ * 并行改码触发插件 client HMR 热重载(保留 console 中装配日志重复 6-7 轮为证)时,
6
+ * EditorView 被重挂载、模块级变量随 bundle 重求值复位——挂载级 `pendingFocusRef`
7
+ * 一并销毁,首开跳转只打开文件、不落目标行,热重载平息后的第二次点击才正常。
8
+ * window 属性跨热重载存活(先例:`__edrvLspProvidersRegistered__`、`__DSH_DEBUG_LOG__`)。
9
+ *
10
+ * 语义约定:
11
+ * - `putPendingNav` 写槽(openFileAt 与挂载级 ref 同步写,ref 是快路径、槽是交接路径);
12
+ * - `readPendingNav` **只读不消费**——过期/形状非法才清;新鲜槽可反复读;
13
+ * - 落点成功后由调用方 `clearPendingNav`(与 ref 同一清除点,防止残留二次跳);
14
+ * - TTL 到点自动失效,避免"稍后莫名跳一下"(用户已离开目标意图窗口)。
15
+ * 作者 ddj 2026-09-23
16
+ */
17
+
18
+ // --region 类型与常量
19
+
20
+ /** 待跳转形状(行列均 1-based;at = 写入时刻,TTL 判据)。 */
21
+ export interface PendingNav {
22
+ path: string
23
+ line?: number | null
24
+ column?: number | null
25
+ endLine?: number | null
26
+ endColumn?: number | null
27
+ at: number
28
+ }
29
+
30
+ /** 写入接口(at 由 putPendingNav 盖时间戳)。 */
31
+ export type PendingNavInput = Omit<PendingNav, 'at'>
32
+
33
+ /** window 槽键名(跨热重载存活的全局挂点)。 */
34
+ export const PENDING_NAV_KEY = '__edrvPendingNav__'
35
+
36
+ /** 交接有效期:超过即失效清槽(点击意图的有效窗口,防陈旧跳转)。 */
37
+ export const PENDING_NAV_TTL = 30000
38
+
39
+ // --endregion
40
+
41
+ // --region 纯函数(可单测)
42
+
43
+ /**
44
+ * 形状守卫:非法入参返回 null,合法则归一化为 PendingNav。
45
+ * 行列非有限数字一律归 null(缺省语义 = 不定位行),path 必须为非空字符串。
46
+ * @author ddj 2026年09月23号
47
+ * @param raw 任意来源的原始值
48
+ * @returns 归一化后的 PendingNav 或 null
49
+ */
50
+ export function sanitizeNav(raw: unknown): PendingNav | null {
51
+ if (!raw || typeof raw !== 'object') return null
52
+ const src = raw as Record<string, unknown>
53
+ if (typeof src.path !== 'string' || !src.path) return null
54
+ const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
55
+ const at = num(src.at)
56
+ if (at === null) return null // at 是 TTL 判据:缺失/非法直接整体无效,不兜 0 免依赖巧合
57
+ return {
58
+ path: src.path,
59
+ line: num(src.line),
60
+ column: num(src.column),
61
+ endLine: num(src.endLine),
62
+ endColumn: num(src.endColumn),
63
+ at,
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 新鲜度判定:形状合法且未超过 TTL 才算有效;过期返回 null。
69
+ * @author ddj 2026年09月23号
70
+ * @param nav 已归一化的待跳转(可空)
71
+ * @param now 当前时刻(毫秒)
72
+ * @param ttl 有效期(毫秒)
73
+ * @returns 有效导航或 null
74
+ */
75
+ export function freshNav(nav: PendingNav | null, now: number, ttl: number): PendingNav | null {
76
+ if (!nav) return null
77
+ return now - nav.at <= ttl ? nav : null
78
+ }
79
+
80
+ // --endregion
81
+
82
+ // --region window 槽适配
83
+
84
+ /** window 上的槽宿主(node 环境无 window,读写降级为 no-op)。 */
85
+ type NavSlotHost = Record<string, unknown>
86
+
87
+ /**
88
+ * 取槽宿主:仅浏览器环境返回 window,否则 undefined(测试/SSR 降级)。
89
+ * @author ddj 2026年09月23号
90
+ */
91
+ function slotHost(): NavSlotHost | undefined {
92
+ if (typeof window === 'undefined') return undefined
93
+ return window as unknown as NavSlotHost
94
+ }
95
+
96
+ /**
97
+ * 写入待跳转(盖写整槽;at 由调用方给定或取当前时间)。
98
+ * 非法 path 直接丢弃,不写槽。
99
+ * @author ddj 2026年09月23号
100
+ * @param nav 待跳转内容(不含 at)
101
+ * @param now 写入时刻(测试注入用)
102
+ * @returns 是否写入成功
103
+ */
104
+ export function putPendingNav(nav: PendingNavInput, now: number = Date.now()): boolean {
105
+ const host = slotHost()
106
+ if (!host) return false
107
+ if (typeof nav?.path !== 'string' || !nav.path) return false
108
+ host[PENDING_NAV_KEY] = { ...nav, at: now }
109
+ return true
110
+ }
111
+
112
+ /**
113
+ * 读取新鲜的待跳转(**不消费**;过期或形状非法才清槽)。
114
+ * @author ddj 2026年09月23号
115
+ * @param now 当前时刻(测试注入用)
116
+ * @param ttl 有效期(毫秒)
117
+ * @returns 有效导航或 null
118
+ */
119
+ export function readPendingNav(now: number = Date.now(), ttl: number = PENDING_NAV_TTL): PendingNav | null {
120
+ const host = slotHost()
121
+ if (!host) return null
122
+ const nav = freshNav(sanitizeNav(host[PENDING_NAV_KEY]), now, ttl)
123
+ if (!nav) {
124
+ // 失效即清:过期/垃圾数据不留在 window 上
125
+ if (host[PENDING_NAV_KEY] !== undefined) delete host[PENDING_NAV_KEY]
126
+ return null
127
+ }
128
+ return nav
129
+ }
130
+
131
+ /**
132
+ * 清除待跳转槽(落点成功或显式放弃时调用)。
133
+ * @author ddj 2026年09月23号
134
+ */
135
+ export function clearPendingNav(): void {
136
+ const host = slotHost()
137
+ if (!host) return
138
+ delete host[PENDING_NAV_KEY]
139
+ }
140
+
141
+ // --endregion
@@ -10,7 +10,7 @@
10
10
  * 作者 ddj 2026-08-26 / 2026-08-27 / 2026-08-31 / 2026-09-10 / 2026-09-16
11
11
  */
12
12
  import React from 'react'
13
- import { FileTypeIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, classifyFileType } from '../../ui/icons.js'
13
+ import { IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, fileIconEl } from '../../ui/icons.js'
14
14
  import { rpc } from '../../rpc.js'
15
15
  import { ContextMenu } from '../../ui/ContextMenu.js'
16
16
  import { buildTreeMenu } from '../contextMenu.js'
@@ -48,20 +48,7 @@ function dirIconEl(isOpen) {
48
48
  return React.createElement(Icon, { size: 16, className: 'edrv-tree-icon' })
49
49
  }
50
50
 
51
- /**
52
- * 文件图标元素(官方 FileTypeIcon + classifyFileType 分类,未知类型为 other)。
53
- * @author ddj 2026年09月10号
54
- * @param name 文件名(含扩展名)
55
- * @returns 图标元素;原语缺失或分类异常返回 null
56
- */
57
- function fileIconEl(name) {
58
- if (typeof FileTypeIcon !== 'function' || typeof classifyFileType !== 'function') return null
59
- try {
60
- return React.createElement(FileTypeIcon, { kind: classifyFileType(String(name ?? '')), size: 16, className: 'edrv-tree-icon' })
61
- } catch (error) {
62
- return null
63
- }
64
- }
51
+ // 文件图标元素(fileIconEl)已上移 icons.ts 共享(SVN 变更行同用),此处直接引用。
65
52
 
66
53
  /**
67
54
  * 行图标分派:目录 → 文件夹图标,文件 → 官方类型图标。