dsh-remote-vs 0.9.0 → 0.9.2

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.
@@ -1,48 +1,115 @@
1
1
  /**
2
- * Remote `@file` insertion into the current session's composer draft: the
3
- * far-side twin of dsh-better-sidebar's `conversation-draft.ts` /
4
- * `selection-payload.ts` (logic vendored, see below). The stock local flow
5
- * projects explorer rows against the host-confirmed workspace root; remote
6
- * rows are POSIX absolutes on another machine, so they project against the
7
- * binding's `remotePath` instead — the same workspace-relative spelling the
8
- * P3 `TransparentFileReferences` router completes, and the `read` tool
9
- * consumes through the fs router.
2
+ * Remote `@file` / selection insertion into the current session's composer
3
+ * draft: the far-side twin of dsh-better-sidebar's `conversation-draft.ts` +
4
+ * `selection-payload.ts`. The stock local flow projects explorer rows against
5
+ * the host-confirmed workspace root; remote rows are POSIX absolutes on
6
+ * another machine, so they project against the binding's `remotePath` instead —
7
+ * the same workspace-relative spelling the P3 `TransparentFileReferences`
8
+ * router completes, and the `read` tool consumes through the fs router.
10
9
  *
11
- * Vendored pieces (behavior-identical to better-sidebar 0.25.0):
12
- * - `spliceInsert` / `insertAtCaret` (whitespace-aware caret splice),
13
- * - `findComposerTextarea` / `probeComposerCaret` /
14
- * `placeComposerCaretAfterInsert` (DOM caret probe; on DSH 0.2.1 the
15
- * composer is a Lexical editor, so the probe misses and inserts append at
16
- * the end — exactly what the local explorer does there),
17
- * - `appendToDraft` (draft read + `setDraft` write through
18
- * `ctx.get('conversation')`, missing service degrades to a logged no-op),
10
+ * Vendored behavior (dsh-better-sidebar **0.28.1**, same semantics):
11
+ * - `leadFor` / `appendText` (the one separating-space rule),
12
+ * - `foldClipboardOffset` / `appendOffset` (the host keeps TWO coordinate
13
+ * systems over the draft: the clipboard projection — `draft` — expands every
14
+ * chip to its whole `clipboardText`, while every insert event's `span` speaks
15
+ * the editor's DETECT projection, one placeholder character per chip; a span
16
+ * derived without folding is rejected by the host on the second chip),
17
+ * - `insertDraftReference` — mint one structured chip through the host's public
18
+ * `insertReference` facade (the method the native `@` picker calls), with a
19
+ * leading space pushed through `slash/input-insert-text` when the draft needs
20
+ * one,
21
+ * - `appendToDraft` — plain text: `setDraft` only for a chipless draft;
22
+ * anything carrying chips (or a claimed `/command`) is written through the
23
+ * span channel, because `setDraft` rebuilds the document from a string and
24
+ * FLATTENS every chip,
25
+ * - `insertReferenceOrText` — the single fallback policy both surfaces use,
19
26
  * - `mentionFor` / `directoryMention` (the `@` quoting grammar),
20
- * - `insertFileReference` (`slash/input-insert-reference`, verified present
21
- * in DSH 0.2.1-alpha.2's dsh-client-ui-conversation contract),
22
- * - `SELECTION_LIMIT` / `buildRemoteSelectionInsert` (the fenced
23
- * `rel:lines` payload shape).
27
+ * - `SELECTION_LIMIT` / `buildRemoteSelectionInsert` (fenced `rel:lines`
28
+ * payload; over the limit the content is dropped and the chip carries the
29
+ * plain path line, so an empty fenced block never occupies the draft).
30
+ *
31
+ * The 0.25.0-era version of this file inserted at the composer's DOM caret and
32
+ * always wrote plain text through `setDraft`; both are gone on purpose — the
33
+ * host publishes no caret for the Lexical composer, and `setDraft` silently
34
+ * destroyed existing chips.
24
35
  *
25
36
  * Remote adaptations (ours):
26
37
  * - `remoteRelativePath`: POSIX-only prefix strip against `remoteRoot`
27
38
  * (case-sensitive; outside/traversal refuses with `undefined`, never an
28
39
  * absolute mention — the local `workspaceRelativePath` policy).
29
- * - `insertRemoteReference`: the caller passes `remoteRoot` directly; there
30
- * is no workspaces-membership lookup here because the binding gate lives
31
- * in `use-remote-chat-target.ts` (current session must belong to the
32
- * viewed binding, else the button is disabled — a remote-relative mention
33
- * in a local session would name nothing).
40
+ * - `insertRemoteReference`: the caller passes `remoteRoot` directly; there is
41
+ * no workspaces-membership lookup here because the binding gate lives in
42
+ * `use-remote-chat-target.ts` (current session must belong to the viewed
43
+ * binding, else the button is disabled).
34
44
  * - `sameLocalDir`: Windows-tolerant alias-dir comparison for that gate.
35
45
  *
36
- * No ctx reads at module scope (explicit `ctx` params only) so every
37
- * function degrades to a logged no-op when a web-core service is missing,
38
- * and the pure pieces stay unit-testable. DOM-only: no Node builtins (the
39
- * client purity gate forbids them).
46
+ * No ctx reads at module scope (explicit `ctx` params only) so every function
47
+ * degrades to a logged no-op when a web-core service is missing, and the pure
48
+ * pieces stay unit-testable. DOM-only: no Node builtins (the client purity gate
49
+ * forbids them).
40
50
  */
41
51
  import type { Context } from '@deepseek-ai/cordis'
42
52
 
53
+ /** One reference chip's footprint in clipboard-projection coordinates. */
54
+ export interface OccurrenceLike {
55
+ /** Start offset in the clipboard projection (same plane as `draft`). */
56
+ offset: number
57
+ /** Length in the clipboard projection (the chip's whole `clipboardText`). */
58
+ length: number
59
+ }
60
+
61
+ /** Pick-time draft span guarded by the input revision (detect coordinates). */
62
+ export interface TokenSpan {
63
+ start: number
64
+ end: number
65
+ draftRev: number
66
+ }
67
+
68
+ /** The host's structured-reference payload (mirror of `ReferenceInsert`). */
69
+ export interface ReferenceInsert {
70
+ /** Trigger source id; `'reference'` for a programmatic insert. */
71
+ source: string
72
+ /** What the model receives on send — the chip's full serialized form. */
73
+ ref: string
74
+ /** What the composer shows in place of `ref`. */
75
+ label: string
76
+ /** Chip icon family; the host picks its own default when omitted. */
77
+ appearance?: 'session' | 'file' | 'folder'
78
+ /** The text this chip occupies in the draft projection. */
79
+ clipboardText: string
80
+ }
81
+
82
+ /**
83
+ * One reference to insert: the compact `label` the composer shows, and the
84
+ * `text` the model receives on send. The two are deliberately different — a
85
+ * selection reads as `docs/a.md:12-15` on screen while the model still gets the
86
+ * whole fenced block. A plain-text fallback inserts `text` (never the label),
87
+ * so a chip failure still delivers the payload the user meant.
88
+ */
89
+ export interface PendingReference {
90
+ label: string
91
+ text: string
92
+ /** What the draft string carries for this chip; defaults to `label`. */
93
+ clipboardText?: string
94
+ /** Chip icon family. Omit when `text` is not a path (a selection). */
95
+ appearance?: 'file' | 'folder' | 'session'
96
+ }
97
+
98
+ /** Snapshot the insertion logic reads (structural slice of the host's InputState). */
99
+ interface InputSnapshot {
100
+ draft: string
101
+ draftRev?: number
102
+ occurrences?: readonly OccurrenceLike[]
103
+ phase?: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
104
+ claim?: { readonly token: string }
105
+ }
106
+
43
107
  /** Structural slice of the web-core conversation service we touch. */
44
108
  interface SessionInputLike {
45
- state: { getSnapshot(): { draft: string; draftRev?: number } }
109
+ state: { getSnapshot(): InputSnapshot }
110
+ /** Replace the trigger span with one reference chip (span-CAS'd). Absent on older hosts. */
111
+ insertReference?(ref: ReferenceInsert, span: TokenSpan): boolean
112
+ /** Replace the draft text — flattens every existing chip into its text. */
46
113
  setDraft(text: string): void
47
114
  }
48
115
 
@@ -62,7 +129,11 @@ function conversationOf(ctx: Context): ConversationLike | undefined {
62
129
  }
63
130
  }
64
131
 
65
- function sessionScope(ctx: Context, sessionId: string): unknown {
132
+ interface SessionScopeLike {
133
+ emit?(name: string, payload: unknown): void
134
+ }
135
+
136
+ function sessionScope(ctx: Context, sessionId: string): SessionScopeLike | undefined {
66
137
  // Soft read ONLY: direct `ctx.sessions` property access goes through the
67
138
  // cordis proxy trap and throws `cannot get property "sessions" without
68
139
  // inject` (our client inject is ['slots', 'uiWorkspace'] by design).
@@ -71,166 +142,223 @@ function sessionScope(ctx: Context, sessionId: string): unknown {
71
142
  const sessions = (ctx as unknown as { get(name: string): unknown }).get('sessions') as
72
143
  | { scope?(id: string): unknown }
73
144
  | undefined
74
- return typeof sessions?.scope === 'function' ? sessions.scope(sessionId) : undefined
145
+ return typeof sessions?.scope === 'function' ? (sessions.scope(sessionId) as SessionScopeLike | undefined) : undefined
75
146
  } catch {
76
147
  return undefined
77
148
  }
78
149
  }
79
150
 
80
- /** A resolved composer caret/selection in draft coordinates. */
81
- export interface DraftCaret {
82
- start: number
83
- end: number
84
- }
85
-
86
151
  /**
87
- * The spliced draft plus the caret index (in that draft) right after the
88
- * inserted text — the left-side separating space shifts the caret by one,
89
- * which naive `start + text.length` misses (it would land before the tail).
152
+ * The separating space an append needs on its left — one space, but never
153
+ * doubled against existing whitespace. Both the string append and the span
154
+ * channel (which does no splice math of its own) obey this single rule. A blank
155
+ * draft takes no separator: it is replaced outright.
90
156
  */
91
- interface SpliceResult {
92
- draft: string
93
- caretAfter: number
157
+ function leadFor(draft: string): string {
158
+ if (draft.trim() === '') return ''
159
+ return /\s$/.test(draft) ? '' : ' '
160
+ }
161
+
162
+ /** `draft` with `text` appended behind one separating space when needed. */
163
+ export function appendText(draft: string, text: string): string {
164
+ return `${draft}${leadFor(draft)}${text}`
94
165
  }
95
166
 
96
167
  /**
97
- * Splice `text` into `draft` at `caret` (replacing any live selection) with
98
- * whitespace-aware joins and report the caret position right after the
99
- * inserted text. `caret === null` (position unknown) appends at the end,
100
- * exactly like the original behavior.
168
+ * Fold one clipboard-projection offset to its detect-projection twin.
169
+ *
170
+ * An offset inside a chip's expansion snaps to that chip's trailing edge (the
171
+ * host only ever replaces whole chips, never splits one). An offset exactly at
172
+ * a chip's START resolves to the position BEFORE the chip — the host's own
173
+ * `detectOffsetOfClipboardOffset` reads that case as "inside" and would move a
174
+ * caret one chip too far right.
101
175
  */
102
- function spliceInsert(draft: string, text: string, caret: DraftCaret | null): SpliceResult {
103
- if (caret === null || draft === '') {
104
- const next = draft.trim() === '' ? text : `${draft}${/\s$/.test(draft) ? '' : ' '}${text}`
105
- return { draft: next, caretAfter: next.length }
106
- }
107
- const prefix = draft.slice(0, caret.start)
108
- const suffix = draft.slice(caret.end)
109
- if (prefix === '' && suffix === '') return { draft: text, caretAfter: text.length }
110
- // One separating space, but never doubled against adjacent whitespace
111
- // (or the string edges) — mirrors how typing in the middle of a sentence
112
- // behaves.
113
- const left = prefix === '' || /\s$/.test(prefix) ? '' : ' '
114
- const right = suffix === '' || /^\s/.test(suffix) || /\s$/.test(text) ? '' : ' '
115
- return {
116
- draft: `${prefix}${left}${text}${right}${suffix}`,
117
- caretAfter: prefix.length + left.length + text.length,
176
+ export function foldClipboardOffset(
177
+ draft: string,
178
+ occurrences: readonly OccurrenceLike[],
179
+ offset: number,
180
+ ): number {
181
+ const target = Math.max(0, Math.min(offset, draft.length))
182
+ let shrink = 0
183
+ for (const occurrence of occurrences) {
184
+ if (target >= occurrence.offset + occurrence.length) {
185
+ shrink += occurrence.length - 1
186
+ continue
187
+ }
188
+ if (target > occurrence.offset) return occurrence.offset - shrink + 1
189
+ break
118
190
  }
191
+ return target - shrink
119
192
  }
120
193
 
121
194
  /**
122
- * The spliced draft string (see {@link spliceInsert}); pure string math —
123
- * unit-tested directly.
195
+ * The detect-projection offset that appends at the document end, or
196
+ * `undefined` when it cannot be derived.
197
+ *
198
+ * `foldClipboardOffset` alone is not enough: a command chip (`/goal `, present
199
+ * while `phase === 'claimed'`) collapses to one detect character but never
200
+ * appears in `occurrences` — the host only collects REFERENCE chips.
201
+ *
202
+ * CORRECTION over the upstream 0.28.1 formula: the chip's clipboard text is
203
+ * `claim.token.trimEnd()` and the separator is a SEPARATE text node, so the
204
+ * detect projection is `1 (chip) + separator.length + rest`. Upstream folds the
205
+ * whole `claim.token` into that one placeholder, which lands one character
206
+ * short — a selection or `@file` appended while a slash command is claimed then
207
+ * splices INSIDE the command's last argument word (`/goal do it` became
208
+ * `/goal do i <payload>t`). Verified against the installed host:
209
+ * `CommandChipNode.getTextContent()` returns the token without its separator,
210
+ * and `insertCommand` inserts the separator as its own text node.
124
211
  */
125
- export function insertAtCaret(draft: string, text: string, caret: DraftCaret | null): string {
126
- return spliceInsert(draft, text, caret).draft
212
+ export function appendOffset(snapshot: InputSnapshot): number | undefined {
213
+ const { draft, claim } = snapshot
214
+ const occurrences = snapshot.occurrences ?? []
215
+ const token = snapshot.phase === 'claimed' && claim !== undefined && draft.startsWith(claim.token)
216
+ ? claim.token
217
+ : ''
218
+ if (token === '') return foldClipboardOffset(draft, occurrences, draft.length)
219
+ // The chip covers the token WITHOUT its trailing separator; the separator is
220
+ // a text node and keeps its own characters in the detect projection.
221
+ const separator = token.slice(token.trimEnd().length)
222
+ const rest = draft.slice(token.length)
223
+ return 1 + separator.length + foldClipboardOffset(
224
+ rest,
225
+ occurrences
226
+ .filter(occurrence => occurrence.offset >= token.length)
227
+ .map(occurrence => ({ offset: occurrence.offset - token.length, length: occurrence.length })),
228
+ rest.length,
229
+ )
127
230
  }
128
231
 
129
232
  /**
130
- * Locate the composer `<textarea>` in the conversation column: prefer the
131
- * `data-phase`-tagged textarea (the composer's marker), falling back to any
132
- * textarea in the column, then to a bare data-phase textarea (older host
133
- * layouts without the column attribute). Null in jsdom-less hosts — and on
134
- * DSH 0.2.1, whose composer is a Lexical contenteditable (the probe then
135
- * misses and callers append at the end, matching the local explorer).
233
+ * Whether the session's draft revision moved past `revision` — the only
234
+ * trustworthy success signal after an exception swallowed the host's answer.
136
235
  */
137
- function findComposerTextarea(): HTMLTextAreaElement | null {
138
- if (typeof document === 'undefined') return null
139
- const column = document.querySelector('#root [data-slot="conversation"]')
140
- const find = (scope: ParentNode): HTMLTextAreaElement | null =>
141
- scope.querySelector('textarea[data-phase]') ?? scope.querySelector('textarea')
142
- return column !== null
143
- ? find(column)
144
- : document.querySelector<HTMLTextAreaElement>('textarea[data-phase]')
236
+ function revisionChanged(ctx: Context, sessionId: string, revision: number | undefined): boolean {
237
+ try {
238
+ const actx = sessionScope(ctx, sessionId)
239
+ if (actx === undefined) return false
240
+ const now = conversationOf(ctx)?.input.for(actx).state.getSnapshot().draftRev
241
+ if (now === undefined || revision === undefined) return false
242
+ return now !== revision
243
+ } catch {
244
+ return false
245
+ }
145
246
  }
146
247
 
147
- /**
148
- * Resolve the composer's live caret from its DOM `<textarea>`. The draft
149
- * store has no caret API, so the value-sync check (`el.value === draft`)
150
- * discards stale or wrong-composer reads — a caret must never be applied
151
- * against a draft it was not measured on.
152
- *
153
- * Returns null when the composer is missing, disabled/read-only, out of
154
- * sync with the store draft, or has no measurable selection (jsdom/odd
155
- * hosts report null selectionStart/End).
156
- */
157
- export function probeComposerCaret(draft: string): DraftCaret | null {
158
- const el = findComposerTextarea()
159
- if (el === null || el.disabled || el.readOnly) return null
160
- if (el.value !== draft) return null
161
- let start = el.selectionStart
162
- let end = el.selectionEnd
163
- if (typeof start !== 'number' || typeof end !== 'number') return null
164
- if (!Number.isFinite(start) || !Number.isFinite(end)) return null
165
- start = Math.max(0, Math.min(start, draft.length))
166
- end = Math.max(start, Math.min(end, draft.length))
167
- return { start, end }
248
+ /** One session's resolved input facade, or a logged reason why not. */
249
+ function inputFor(
250
+ ctx: Context,
251
+ sessionId: string,
252
+ reason: string,
253
+ ): { actx: SessionScopeLike; input: SessionInputLike } | undefined {
254
+ const actx = sessionScope(ctx, sessionId)
255
+ if (actx === undefined) {
256
+ console.warn(`[dsh-remote-vs] ${reason}: no session scope`, sessionId)
257
+ return undefined
258
+ }
259
+ const conversation = conversationOf(ctx)
260
+ if (conversation === undefined) {
261
+ console.warn(`[dsh-remote-vs] ${reason}: conversation service unavailable`)
262
+ return undefined
263
+ }
264
+ try {
265
+ return { actx, input: conversation.input.for(actx) }
266
+ } catch (error) {
267
+ console.warn(`[dsh-remote-vs] ${reason}: input facade unavailable`, error)
268
+ return undefined
269
+ }
168
270
  }
169
271
 
170
272
  /**
171
- * Restore the composer caret to `caretIndex` after a programmatic
172
- * `setDraft` commit. A controlled textarea update resets the caret (React
173
- * commits the value asynchronously and the browser moves the caret to the
174
- * start/end), so the placement is scheduled and retried across at most two
175
- * animation frames (setTimeout fallback for jsdom), and only applied when
176
- * the textarea still matches `expectedDraft` — a newer edit or a different
177
- * composer wins the race untouched. The caret is clamped into the value
178
- * bounds, mirroring how browsers clamp type-in positions.
273
+ * Mint one reference chip through the host's public `insertReference` facade —
274
+ * the same method the native `@` picker's event listener calls, so the
275
+ * composer shows a compact chip while the model receives `reference.text`.
276
+ * The host runs its own admission-phase and revision guards and reports whether
277
+ * the edit applied.
278
+ *
279
+ * The chip's `clipboardText` is the compact `label` by default, not the full
280
+ * payload: other draft consumers (copy, plain projections) stay short, and the
281
+ * full payload rides in `text` for send.
282
+ *
283
+ * @returns false when the host refused the insert (stale revision, frozen
284
+ * submission phase, or no chip channel) so the caller can fall back.
179
285
  */
180
- export function placeComposerCaretAfterInsert(expectedDraft: string, caretIndex: number): void {
181
- let remaining = 2
182
- let scheduled = false
183
- const schedule = (fn: () => void): void => {
184
- if (scheduled) return
185
- scheduled = true
186
- if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fn)
187
- else setTimeout(fn, 0)
188
- }
189
- const place = (): void => {
190
- scheduled = false
191
- if (remaining <= 0) return
192
- remaining -= 1
193
- const el = findComposerTextarea()
194
- if (el === null || el.disabled || el.readOnly) return
195
- if (el.value !== expectedDraft) {
196
- // The commit has not landed yet (or a competing edit won) — one more
197
- // frame before giving up.
198
- schedule(place)
199
- return
286
+ export function insertDraftReference(
287
+ ctx: Context,
288
+ sessionId: string,
289
+ reference: PendingReference,
290
+ ): boolean {
291
+ let before: number | undefined
292
+ try {
293
+ const resolved = inputFor(ctx, sessionId, 'reference chip skipped')
294
+ if (resolved === undefined) return false
295
+ const { actx, input } = resolved
296
+ const snapshot = input.state.getSnapshot()
297
+ if (input.insertReference === undefined || snapshot.draftRev === undefined) return false
298
+ if (snapshot.phase === 'adjudicating' || snapshot.phase === 'submitting') return false
299
+ before = snapshot.draftRev
300
+ const at = appendOffset(snapshot)
301
+ if (at === undefined) return false
302
+ let rev = snapshot.draftRev
303
+ let start = at
304
+ // The host adds a space AFTER a chip, never before it; a chip appended to
305
+ // a non-empty draft needs its own separator, pushed through the span
306
+ // channel so the chip insertion itself stays a pure replacement.
307
+ if (leadFor(snapshot.draft) !== '') {
308
+ actx.emit?.('slash/input-insert-text', {
309
+ text: ' ',
310
+ span: { draftRev: rev, start: at, end: at },
311
+ })
312
+ const afterSpace = input.state.getSnapshot()
313
+ if (afterSpace.draftRev === rev) return false
314
+ rev = afterSpace.draftRev ?? rev
315
+ start = at + 1
316
+ }
317
+ const chip: ReferenceInsert = {
318
+ source: 'reference',
319
+ ref: reference.text,
320
+ label: reference.label,
321
+ ...(reference.appearance === undefined ? {} : { appearance: reference.appearance }),
322
+ clipboardText: reference.clipboardText ?? reference.label,
200
323
  }
201
- const clamped = Math.max(0, Math.min(caretIndex, el.value.length))
202
- el.setSelectionRange(clamped, clamped)
324
+ return input.insertReference(chip, { draftRev: rev, start, end: start })
325
+ } catch (error) {
326
+ console.warn('[dsh-remote-vs] reference chip insert failed:', error)
327
+ return revisionChanged(ctx, sessionId, before)
203
328
  }
204
- schedule(place)
205
329
  }
206
330
 
207
331
  /**
208
- * Insert `text` into the session's composer draft at the composer's live
209
- * caret (see {@link probeComposerCaret}), falling back to appending at the
210
- * end when the caret cannot be resolved. Returns false — and logs — when the
211
- * conversation service or the session scope is unavailable.
332
+ * Append `text` to the session's composer draft. Returns false — and logs —
333
+ * when the conversation service or the session scope is unavailable.
334
+ *
335
+ * A chipless draft takes the plain `setDraft` path. A draft that CARRIES chips
336
+ * is written through `slash/input-insert-text` at the folded end offset
337
+ * instead: `setDraft` rebuilds the document from a string and flattens every
338
+ * chip, which quietly destroys references the user placed by hand. The same
339
+ * span path covers a claimed `/command`, whose prefix chip is not in
340
+ * `occurrences` (see {@link appendOffset}).
212
341
  */
213
342
  export function appendToDraft(ctx: Context, sessionId: string, text: string): boolean {
214
343
  try {
215
- const actx = sessionScope(ctx, sessionId)
216
- if (actx === undefined) {
217
- console.warn('[dsh-remote-vs] draft insert skipped: no session scope', sessionId)
218
- return false
344
+ const resolved = inputFor(ctx, sessionId, 'draft insert skipped')
345
+ if (resolved === undefined) return false
346
+ const { actx, input } = resolved
347
+ const snapshot = input.state.getSnapshot()
348
+ const draft = snapshot.draft
349
+ if ((snapshot.occurrences ?? []).length > 0 || snapshot.phase === 'claimed') {
350
+ const at = appendOffset(snapshot)
351
+ if (snapshot.draftRev === undefined || at === undefined) {
352
+ console.warn('[dsh-remote-vs] draft insert skipped: chip draft without a revision')
353
+ return false
354
+ }
355
+ actx.emit?.('slash/input-insert-text', {
356
+ text: `${leadFor(draft)}${text}`,
357
+ span: { draftRev: snapshot.draftRev, start: at, end: at },
358
+ })
359
+ return input.state.getSnapshot().draftRev !== snapshot.draftRev
219
360
  }
220
- const conversation = conversationOf(ctx)
221
- if (conversation === undefined) {
222
- console.warn('[dsh-remote-vs] draft insert skipped: conversation service unavailable')
223
- return false
224
- }
225
- const input = conversation.input.for(actx)
226
- const draft = input.state.getSnapshot().draft
227
- const caret = probeComposerCaret(draft)
228
- const { draft: next, caretAfter } = spliceInsert(draft, text, caret)
229
- input.setDraft(next)
230
- // Put the caret right after the inserted text once the value commit
231
- // lands — see the module doc for why this keeps stacked inserts at the
232
- // running position.
233
- placeComposerCaretAfterInsert(next, caretAfter)
361
+ input.setDraft(appendText(draft, text))
234
362
  return true
235
363
  } catch (error) {
236
364
  console.warn('[dsh-remote-vs] draft insert failed:', error)
@@ -238,6 +366,20 @@ export function appendToDraft(ctx: Context, sessionId: string, text: string): bo
238
366
  }
239
367
  }
240
368
 
369
+ /**
370
+ * Insert one reference as a chip, degrading to its plain-text form when the
371
+ * host has no chip channel or refuses the insert. This is the ONE place the
372
+ * fallback policy lives: the explorer's `@` button and the viewer selection
373
+ * popup both come through here.
374
+ */
375
+ export function insertReferenceOrText(
376
+ ctx: Context,
377
+ sessionId: string,
378
+ reference: PendingReference,
379
+ ): boolean {
380
+ return insertDraftReference(ctx, sessionId, reference) || appendToDraft(ctx, sessionId, reference.text)
381
+ }
382
+
241
383
  /**
242
384
  * The DSH `@` spelling for one relative path, mirroring the host grammar
243
385
  * (`formatFileMention` in `@deepseek-ai/dsh-file-reference`): plain when
@@ -315,12 +457,12 @@ export function sameLocalDir(left: string, right: string): boolean {
315
457
 
316
458
  /**
317
459
  * Insert one remote explorer entry into the session's composer draft. Files
318
- * mint a structured chip like the native `@` picker
319
- * (`insertFileReference`); directories append the folder mention as plain
320
- * text so DSH's folder decoration and completion keep working. The caller
321
- * owns the binding gate (the session must belong to the viewed binding —
322
- * see `use-remote-chat-target.ts`); an unrepresentable path is a logged
323
- * no-op, never an absolute mention.
460
+ * mint a structured chip exactly like the native `@` picker (same facade the
461
+ * viewer selection popup uses, with a plain-text fallback); directories append
462
+ * the folder mention as plain text so DSH's folder decoration and completion
463
+ * keep working. The caller owns the binding gate (the session must belong to
464
+ * the viewed binding — see `use-remote-chat-target.ts`); an unrepresentable
465
+ * path is a logged no-op, never an absolute mention.
324
466
  */
325
467
  export function insertRemoteReference(
326
468
  ctx: Context | undefined,
@@ -338,63 +480,25 @@ export function insertRemoteReference(
338
480
  console.warn('[dsh-remote-vs] reference insert skipped: path outside the remote workspace root')
339
481
  return false
340
482
  }
341
- const mention = isDir ? directoryMention(relative) : mentionFor(relative, 'file')?.mention
342
- if (mention === undefined) {
483
+ const token = isDir ? directoryMention(relative) : mentionFor(relative, 'file')?.mention
484
+ if (token === undefined) {
343
485
  console.warn('[dsh-remote-vs] reference insert skipped: path cannot be represented as a mention')
344
486
  return false
345
487
  }
346
- if (!isDir && insertFileReference(ctx, sessionId, relative)) return true
347
- // The host adds space after a file chip, but not before it. Complete
348
- // directory inserts need their own separator for the next @ click.
349
- return appendToDraft(ctx, sessionId, isDir ? `${mention} ` : mention)
350
- } catch (error) {
351
- console.warn('[dsh-remote-vs] remote-reference insert failed:', error)
352
- return false
353
- }
354
- }
355
-
356
- /**
357
- * Insert one FILE reference as a structured chip (like DSH's own `@` picker).
358
- * The chip displays `@<basename>` but serializes to `@<relative path>` on
359
- * send, so the reference stays a single link from trigger to basename.
360
- *
361
- * Directories keep the plain-text completion behavior (`@dir/`) through
362
- * `insertRemoteReference`. The rc.1 picker also supports folder chips on
363
- * explicit selection; its drill action still inserts plain text.
364
- */
365
- export function insertFileReference(ctx: Context, sessionId: string, relativePath: string): boolean {
366
- const reference = mentionFor(relativePath, 'file')
367
- if (reference === undefined) return false
368
- try {
369
- const actx = sessionScope(ctx, sessionId)
370
- if (actx === undefined) return false
371
- const conversation = conversationOf(ctx)
372
- if (conversation === undefined) return false
373
- const input = conversation.input.for(actx)
374
- const before = input.state.getSnapshot()
375
- if (before.draftRev === undefined) return false
376
- // The session-scope Context's typed `emit` is keyed to DSH's closed event
377
- // map; this internal composer event is deliberately string-loose at runtime.
378
- // Verified present in DSH 0.2.1-alpha.2 (dsh-client-ui-conversation
379
- // `input.d.ts`: 'slash/input-insert-reference').
380
- ;(actx as unknown as { emit(name: string, payload: unknown): void }).emit('slash/input-insert-reference', {
381
- reference: {
382
- source: 'reference',
383
- ref: reference.mention,
384
- label: reference.label,
385
- appearance: 'file',
386
- clipboardText: reference.mention,
387
- },
388
- span: {
389
- draftRev: before.draftRev,
390
- start: before.draft.length,
391
- end: before.draft.length,
392
- },
488
+ if (isDir) {
489
+ // Complete directory inserts need their own separator for the next @
490
+ // click; the host adds space after a chip but not before it.
491
+ return appendToDraft(ctx, sessionId, `${token} `)
492
+ }
493
+ const label = mentionFor(relative, 'file')?.label ?? token
494
+ return insertReferenceOrText(ctx, sessionId, {
495
+ label,
496
+ text: token,
497
+ clipboardText: token,
498
+ appearance: 'file',
393
499
  })
394
- const after = input.state.getSnapshot()
395
- return after.draftRev !== before.draftRev
396
500
  } catch (error) {
397
- console.warn('[dsh-remote-vs] file-reference insert failed:', error)
501
+ console.warn('[dsh-remote-vs] remote-reference insert failed:', error)
398
502
  return false
399
503
  }
400
504
  }
@@ -420,17 +524,22 @@ export function remoteHeaderOf(remoteRoot: string, absPath: string, lines?: Sele
420
524
  }
421
525
 
422
526
  /**
423
- * The full text appended to the composer draft for one selection.
424
- * Over the limit the content is dropped: the plain path line is the whole
425
- * payload (an empty fenced block would just occupy the draft).
527
+ * The composer payload for one selection: the chip label and the model text.
528
+ *
529
+ * The chip reads as `rel:start-end` (compact, and it is what the draft
530
+ * projection carries); the model receives the whole fenced block. Over the
531
+ * limit the content is dropped and BOTH halves are the plain path line — an
532
+ * empty fenced block would just occupy the draft. No `appearance` is set: the
533
+ * host can make `'file'` chips openable, and a selection's `text` is not a
534
+ * path, so a click must not hand a fenced block to the file opener.
426
535
  */
427
536
  export function buildRemoteSelectionInsert(
428
537
  absPath: string,
429
538
  remoteRoot: string,
430
539
  lines: SelectionLines | undefined,
431
540
  selected: string,
432
- ): string {
541
+ ): PendingReference {
433
542
  const header = remoteHeaderOf(remoteRoot, absPath, lines)
434
- if (selected.length > SELECTION_LIMIT) return header
435
- return `\`\`\`${header}\n${selected}\n\`\`\``
543
+ if (selected.length > SELECTION_LIMIT) return { label: header, text: header }
544
+ return { label: header, text: `\`\`\`${header}\n${selected}\n\`\`\`` }
436
545
  }