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.
- package/lib/client.js +231 -171
- package/lib/index.js +1 -1
- package/lib/p2-router-fs.js +1 -1
- package/lib/types/client/composer.d.ts +158 -75
- package/lib/types/client/selection-popup.d.ts +8 -8
- package/lib/types/workspace-title.d.ts +2 -2
- package/lib/{worlds-DnzpX3Hv.js → worlds-a8nqW9mi.js} +2 -2
- package/package.json +1 -1
- package/src/client/CodeEditor.tsx +6 -3
- package/src/client/composer.ts +329 -220
- package/src/client/selection-popup.ts +12 -12
- package/src/workspace-title.ts +2 -2
package/src/client/composer.ts
CHANGED
|
@@ -1,48 +1,115 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Remote `@file` insertion into the current session's composer
|
|
3
|
-
* far-side twin of dsh-better-sidebar's `conversation-draft.ts`
|
|
4
|
-
* `selection-payload.ts
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
12
|
-
* - `
|
|
13
|
-
* - `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* the
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
* - `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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():
|
|
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
|
-
|
|
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
|
|
88
|
-
*
|
|
89
|
-
* which
|
|
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
|
-
|
|
92
|
-
draft
|
|
93
|
-
|
|
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
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
123
|
-
*
|
|
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
|
|
126
|
-
|
|
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
|
-
*
|
|
131
|
-
*
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
320
|
-
* text so DSH's folder decoration and completion
|
|
321
|
-
* owns the binding gate (the session must belong to
|
|
322
|
-
* see `use-remote-chat-target.ts`); an unrepresentable
|
|
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
|
|
342
|
-
if (
|
|
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 (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
return
|
|
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]
|
|
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
|
|
424
|
-
*
|
|
425
|
-
*
|
|
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
|
-
):
|
|
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
|
}
|