dsh-plugin-workbench 0.0.11 → 0.0.13

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/index.js CHANGED
@@ -9,7 +9,9 @@ const name = "dsh-plugin-workbench";
9
9
  const inject = [
10
10
  "fs",
11
11
  "connection",
12
- "webServer"
12
+ "webServer",
13
+ "systemPrompt",
14
+ "agents"
13
15
  ];
14
16
  /** Loopback-only logical RPC channel. */
15
17
  const CHANNEL = "/dsh-plugin-files";
@@ -229,11 +231,43 @@ function ensureLayoutPatch() {
229
231
  }
230
232
  }
231
233
  /**
234
+ * Stable system-prompt section teaching the model the workbench `@.\` mention
235
+ * grammar. The core already contributes a generic "paths prefixed with @ are
236
+ * files referenced by the user" section (dsh-file-reference-local, order 99);
237
+ * this one documents the actual syntax the GUI inserts (the `.\` workspace
238
+ * marker, quoted form for paths with spaces) so the model resolves mentions
239
+ * against the session workspace root instead of treating `@.\…` as noise.
240
+ * Static text only — the string is identical on every assembly (KV-cache safe).
241
+ */
242
+ const FILE_MENTION_PROMPT = "Workspace file mentions written by the file panel use the form `@.<relative-path>` (a `.` or `./` prefix marks a path relative to the current session workspace root; both `\\` and `/` separators are accepted, and paths with spaces use the quoted form `@\"<relative-path>\"`, for example `@\"\\.\\my plan.md\"`). Treat any such mention as a file the user wants you to read or edit: resolve the path against the workspace root and act on it with the file tools (read, glob, grep, edit, write); never claim to have inspected a file you did not actually open.";
243
+ /**
244
+ * File-mention grammar taught to the model (see the FILE_MENTION_PROMPT doc).
245
+ * One section per agent fiber: existing agents at apply time plus every agent
246
+ * created later (agent/created). The core already contributes a generic "paths
247
+ * prefixed with @ are files" section (dsh-file-reference-local, order 99);
248
+ * order 100 puts this more specific grammar right after it.
249
+ */
250
+ function installMentionPrompt(ctx) {
251
+ const teach = (agent) => {
252
+ agent.ctx.systemPrompt.section({
253
+ name: "dsh-plugin-workbench:file-mention",
254
+ order: 100,
255
+ text: FILE_MENTION_PROMPT
256
+ });
257
+ };
258
+ for (const agent of ctx.agents.list()) teach(agent);
259
+ ctx.on("agent/created", (payload) => {
260
+ const { agent } = payload;
261
+ teach(agent);
262
+ });
263
+ }
264
+ /**
232
265
  * One filesystem-backed RPC endpoint pair. Reads never mutate; `signal`
233
266
  * cancels the underlying fs call (or aborts between steps).
234
267
  */
235
268
  function apply(ctx) {
236
269
  ensureLayoutPatch();
270
+ installMentionPrompt(ctx);
237
271
  const watchState = {
238
272
  dirs: /* @__PURE__ */ new Map(),
239
273
  files: /* @__PURE__ */ new Map(),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-workbench",
3
3
  "description": "VS Code-style workspace file explorer + editable preview for the dsh web GUI",
4
- "version": "0.0.11",
4
+ "version": "0.0.13",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.21.0",
7
7
  "engines": {
@@ -10,7 +10,7 @@ import { FileIcon } from './fileIcons'
10
10
  import type { FilesKey } from './locales'
11
11
  import { cancelCut, clearClipboard, closeFilesUnder, copyToClipboard, expandPreview, openFile, popUndo, pushUndo, retargetFile, setCwd, toggleTheme, useClipboard, useTabsState } from './store'
12
12
  import type { ClipboardItem, ClipboardMode, UndoEntry } from './store'
13
- import { DRAG_TYPE, insertIntoComposer, relPathOf } from './composer'
13
+ import { DRAG_TYPE, composerMention, insertIntoComposer, relPathOf } from './composer'
14
14
 
15
15
  export interface FsListEntry {
16
16
  name: string
@@ -950,11 +950,14 @@ export function FileExplorer({
950
950
  setSelected(all)
951
951
  }, [children, expanded, root])
952
952
 
953
- /** Insert `@<relative-workspace-path>` into the composer (Claude Code-style mention). */
953
+ /**
954
+ * Insert an `@.\<relative-workspace-path>` mention into the composer (the
955
+ * `.\` prefix marks the path as workspace-relative; see composerMention).
956
+ */
954
957
  const onMention = useCallback((path: string) => {
955
958
  const mention = relPathOf(path, cwd)
956
959
  if (mention.length === 0) return
957
- insertIntoComposer(`@${mention} `)
960
+ insertIntoComposer(`${composerMention(mention)} `)
958
961
  }, [cwd])
959
962
 
960
963
  // Explorer-style keyboard: Ctrl/Cmd+C/X copy-cut, Ctrl/Cmd+V paste,
@@ -47,6 +47,22 @@ const PREVIEW_MIN = 240
47
47
  const CHAT_MIN = 240
48
48
  const PREVIEW_TOO_LARGE_LABEL = '512KB'
49
49
 
50
+ /** Persisted split width (px) — survives reloads so a drag is never lost. */
51
+ const PREVIEW_WIDTH_KEY = 'dsh-plugin-workbench:preview-width'
52
+
53
+ /** Read the persisted preview width; `null` means "use the default 55%". */
54
+ function storedPreviewWidth(): number | null {
55
+ try {
56
+ if (typeof window === 'undefined') return null
57
+ const raw = window.localStorage.getItem(PREVIEW_WIDTH_KEY)
58
+ if (raw === null) return null
59
+ const px = Number(raw)
60
+ return Number.isFinite(px) && px > 0 ? px : null
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
50
66
  /** Same-origin raw-bytes route registered by the host half (see src/index.ts). */
51
67
  const RAW_PREFIX = '/dsh-plugin-files/raw'
52
68
 
@@ -108,6 +124,23 @@ function clamp(value: number, min: number, max: number): number {
108
124
  return Math.min(max, Math.max(min, value))
109
125
  }
110
126
 
127
+ /**
128
+ * Nearest ancestor that actually takes part in layout. The slot system wraps
129
+ * each slot's content in a `display: contents` element: children still join
130
+ * the OUTER flex row, but the wrapper itself reports a 0×0 bounding rect.
131
+ * Measuring that (the old `handle.parentElement`) made `max` collapse to
132
+ * `PREVIEW_MIN` on the first move — the pane jumped to its minimum width and
133
+ * could never be dragged back out. Skip every `display: contents` layer.
134
+ */
135
+ function laidOutParent(el: HTMLElement | null): HTMLElement | null {
136
+ let node = el?.parentElement ?? null
137
+ while (node !== null) {
138
+ if (getComputedStyle(node).display !== 'contents') return node
139
+ node = node.parentElement
140
+ }
141
+ return null
142
+ }
143
+
111
144
  /** Parent directory of a path ('C:/a/b.md' → 'C:/a'; '' when there is none). */
112
145
  function dirnameOf(path: string): string {
113
146
  const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
@@ -163,6 +196,10 @@ export function FilePreview({ t, readFile, writeFile, watchFiles }: FilePreviewP
163
196
  const highlightRef = useRef<HTMLPreElement>(null)
164
197
  const textareaRef = useRef<HTMLTextAreaElement>(null)
165
198
  const gutterRef = useRef<HTMLDivElement>(null)
199
+ // Live drag listeners, kept in refs so a new drag can ALWAYS clear any
200
+ // leftovers (a lost pointerup must not leak a stale onMove into the page).
201
+ const dragMoveRef = useRef<(ev: PointerEvent) => void>(() => undefined)
202
+ const dragUpRef = useRef<() => void>(() => undefined)
166
203
 
167
204
  const refresh = useCallback(() => bump((v) => v + 1), [])
168
205
 
@@ -391,26 +428,89 @@ export function FilePreview({ t, readFile, writeFile, watchFiles }: FilePreviewP
391
428
  }
392
429
  }, [isOpen])
393
430
 
431
+ // Apply the persisted width once the pane gets its first real layout: the
432
+ // saved px may exceed the CURRENT center column (window resized since the
433
+ // last drag), so clamp it against a live measurement before applying —
434
+ // otherwise a stale wide value would squeeze the chat seat to nothing.
435
+ useEffect(() => {
436
+ const saved = storedPreviewWidth()
437
+ if (saved === null) return
438
+ const preview = previewRef.current
439
+ const center = laidOutParent(preview)
440
+ if (preview === null || preview === undefined || center === null || center === undefined) return
441
+ const centerWidth = center.getBoundingClientRect().width
442
+ const max = Math.max(PREVIEW_MIN, centerWidth - CHAT_MIN)
443
+ setPreviewWidth(clamp(saved, PREVIEW_MIN, max))
444
+ }, [isOpen])
445
+
446
+ // Unmount during a drag (slot removed mid-drag): drop the window listeners
447
+ // so no stale move handler survives to rewrite widths in the next mount.
448
+ useEffect(() => () => {
449
+ window.removeEventListener('pointermove', dragMoveRef.current)
450
+ window.removeEventListener('pointerup', dragUpRef.current)
451
+ window.removeEventListener('pointercancel', dragUpRef.current)
452
+ }, [])
453
+
454
+ /**
455
+ * Start a split-width drag. Robustness notes (the "pane suddenly shrinks
456
+ * and freezes" bug class):
457
+ *
458
+ * - Pointer capture reroutes every later pointer event to the handle, so
459
+ * `pointerup` fires EVEN when the mouse is released outside the window.
460
+ * Without it the up event is lost, `onUp` never runs, and the leftover
461
+ * `onMove` keeps rewriting the width from its stale baseline on every
462
+ * mouse move anywhere on the page — that is the "cannot drag / cannot
463
+ * restore" state.
464
+ * - `pointercancel` is cleaned up too (browser steals the pointer, e.g. a
465
+ * tablet palm or an OS gesture).
466
+ * - Pointerdown defensively removes any previous listeners first, so even a
467
+ * capture-less leftover cannot survive into a second drag.
468
+ * - The max is re-measured every move: the chat minimum is relative to the
469
+ * CURRENT center column, which can change while the drag is in flight.
470
+ */
394
471
  const onHandleDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
395
472
  e.preventDefault()
396
473
  const handle = handleRef.current
397
474
  const preview = previewRef.current
398
475
  if (handle === null || preview === null) return
399
- const center = handle.parentElement
476
+ // NOT handle.parentElement: the slot wrapper is `display: contents` and
477
+ // measures 0×0 — the flex container to clamp against is one level up (see
478
+ // laidOutParent). Measuring the wrapper made every drag snap to
479
+ // PREVIEW_MIN and then stick (the reported "sudden shrink / can't drag").
480
+ const center = laidOutParent(handle)
400
481
  if (center === null) return
401
482
  const startX = e.clientX
402
483
  const startWidth = preview.getBoundingClientRect().width
403
- const centerWidth = center.getBoundingClientRect().width
404
- const max = Math.max(PREVIEW_MIN, centerWidth - CHAT_MIN)
405
484
  const onMove = (ev: PointerEvent) => {
406
- setPreviewWidth(clamp(startWidth + ev.clientX - startX, PREVIEW_MIN, max))
485
+ const centerWidth = center.getBoundingClientRect().width
486
+ const max = Math.max(PREVIEW_MIN, centerWidth - CHAT_MIN)
487
+ const width = clamp(startWidth + ev.clientX - startX, PREVIEW_MIN, max)
488
+ setPreviewWidth(width)
489
+ try {
490
+ window.localStorage.setItem(PREVIEW_WIDTH_KEY, String(width))
491
+ } catch {
492
+ // storage unavailable — the width just won't persist across reloads
493
+ }
407
494
  }
408
495
  const onUp = () => {
409
- window.removeEventListener('pointermove', onMove)
410
- window.removeEventListener('pointerup', onUp)
496
+ window.removeEventListener('pointermove', dragMoveRef.current)
497
+ window.removeEventListener('pointerup', dragUpRef.current)
498
+ window.removeEventListener('pointercancel', dragUpRef.current)
411
499
  }
500
+ // Defensive reset of any previous drag (see the doc comment above).
501
+ window.removeEventListener('pointermove', dragMoveRef.current)
502
+ window.removeEventListener('pointerup', dragUpRef.current)
503
+ window.removeEventListener('pointercancel', dragUpRef.current)
504
+ dragMoveRef.current = onMove
505
+ dragUpRef.current = onUp
412
506
  window.addEventListener('pointermove', onMove)
413
507
  window.addEventListener('pointerup', onUp)
508
+ window.addEventListener('pointercancel', onUp)
509
+ try {
510
+ handle.setPointerCapture(e.pointerId)
511
+ } catch {
512
+ // Pointer capture unsupported — the window listeners still cover the drag.
513
+ }
414
514
  }, [])
415
515
 
416
516
  const onSave = useCallback(async () => {
@@ -1,17 +1,24 @@
1
1
  /**
2
2
  * Composer integration for the workbench file column.
3
3
  *
4
- * Two gestures land text in the chat composer without touching the core:
4
+ * Three gestures land text in the chat composer without touching the core:
5
5
  *
6
6
  * 1. Drag & drop — dragging one or more tree rows and dropping ANYWHERE
7
7
  * outside the file column (the chat, the composer, the message list)
8
- * inserts the dragged paths into the composer. Dropping INSIDE the file
9
- * column still performs the tree's own move operation the tree's drop
10
- * handler runs first and stops propagation, so this document-level
11
- * listener never sees those drops.
8
+ * inserts the dragged paths into the composer as `@.\` mentions (falling
9
+ * back to the absolute path when the file sits outside the workspace).
10
+ * Dropping INSIDE the file column still performs the tree's own move
11
+ * operation the tree's drop handler runs first and stops propagation, so
12
+ * this document-level listener never sees those drops.
12
13
  *
13
- * 2. Context-menu "@引用" — inserts `@<relative-workspace-path>` at the
14
- * composer caret.
14
+ * 2. Context-menu "@引用" — inserts `@.\<relative-workspace-path>` at the
15
+ * composer caret (the `.\` prefix marks the path as workspace-relative; a
16
+ * path containing whitespace uses the quoted `@"\.\path with space"` form).
17
+ *
18
+ * 3. @-mention resolution — turns a mention token (with or without the `.\`
19
+ * prefix, quoted or plain) into an absolute path against the session cwd;
20
+ * used by the message linkifier and the composer overlay to open the file
21
+ * in the workbench preview.
15
22
  *
16
23
  * The composer is a controlled React textarea, so the value is updated
17
24
  * through the native `value` setter + a bubbling `input` event (the standard
@@ -41,11 +48,23 @@ function onDocumentDrop(e: DragEvent): void {
41
48
  if (dt === null || !dt.types.includes(DRAG_TYPE)) return
42
49
  const paths = readDraggedPaths(dt)
43
50
  if (paths.length === 0) return
44
- if (!insertIntoComposer(paths.join('\n'))) return
51
+ if (!insertIntoComposer(paths.map(dragMentionText).join('\n'))) return
45
52
  e.preventDefault()
46
53
  e.stopPropagation()
47
54
  }
48
55
 
56
+ /**
57
+ * One dropped path as chat text: an `@.\` mention when it lives under the
58
+ * workspace, the absolute path otherwise (or when no cwd is known yet).
59
+ */
60
+ function dragMentionText(path: string): string {
61
+ const { cwd } = getTabsState()
62
+ if (cwd === undefined) return path
63
+ const rel = relPathOf(path, cwd)
64
+ if (rel.length === 0 || rel === path) return path
65
+ return composerMention(rel)
66
+ }
67
+
49
68
  /** Read the JSON paths from the custom type; falls back to raw text. */
50
69
  export function readDraggedPaths(dt: DataTransfer | null): string[] {
51
70
  if (dt === null) return []
@@ -118,17 +137,41 @@ export function relPathOf(path: string, cwd: string | undefined): string {
118
137
  }
119
138
 
120
139
  /**
121
- * Resolve an @-mention path (workspace-relative or absolute) against the
122
- * current workspace cwd; returns the absolute OS path, or undefined when the
123
- * mention cannot be resolved (no cwd and the path is not absolute).
140
+ * Format a workspace-relative path as an `@` mention for the composer: the
141
+ * `.\` (or `./`) prefix marks the path as relative to the workspace root, and
142
+ * a path containing whitespace uses the quoted `@"..."` form so it stays one
143
+ * token in the draft (and one link in the rendered message).
144
+ */
145
+ export function composerMention(rel: string): string {
146
+ const sep = rel.includes('\\') ? '\\' : '/'
147
+ const raw = `@.${sep}${rel}`
148
+ if (/[\s"]/.test(raw)) return `@"${raw}"`
149
+ return raw
150
+ }
151
+
152
+ /** Strip quote wrapping and a leading `.\` / `./` workspace marker. */
153
+ function normalizeMention(mention: string): string {
154
+ let inner = mention
155
+ if (inner.startsWith('"') && inner.endsWith('"') && inner.length >= 2) inner = inner.slice(1, -1)
156
+ if (inner.startsWith('.\\') || inner.startsWith('./')) inner = inner.slice(2)
157
+ return inner
158
+ }
159
+
160
+ /**
161
+ * Resolve an @-mention path (workspace-relative with or without the `.\`
162
+ * marker, or absolute) against the current workspace cwd; returns the absolute
163
+ * OS path, or undefined when the mention cannot be resolved (no cwd and the
164
+ * path is not absolute).
124
165
  */
125
166
  export function resolveMentionPath(mention: string): string | undefined {
126
- const absolute = /^[A-Za-z]:[\\/]/.test(mention) || mention.startsWith('/') || mention.startsWith('\\')
167
+ const inner = normalizeMention(mention)
168
+ if (inner.length === 0) return undefined
169
+ const absolute = /^[A-Za-z]:[\\/]/.test(inner) || inner.startsWith('/') || inner.startsWith('\\')
127
170
  const { cwd } = getTabsState()
128
- if (absolute) return mention
171
+ if (absolute) return inner
129
172
  if (cwd === undefined) return undefined
130
173
  const sep = cwd.includes('\\') ? '\\' : '/'
131
- return cwd.endsWith('\\') || cwd.endsWith('/') ? cwd + mention : cwd + sep + mention
174
+ return cwd.endsWith('\\') || cwd.endsWith('/') ? cwd + inner : cwd + sep + inner
132
175
  }
133
176
 
134
177
  /** Open an @-mention's file in the workbench preview (used by the linkifier). */
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Composer @-mention hyperlink enhancement.
3
+ *
4
+ * The composer's visible text lives in a core-rendered backdrop (`textarea`
5
+ * text is transparent) that React re-renders on every keystroke, so plugin
6
+ * code must not mutate it. Instead this module renders its OWN overlay — an
7
+ * absolutely-positioned copy of the draft, in the same font/padding/wrap
8
+ * metrics as the textarea (copied from the core `.input,.mirror,.backdrop`
9
+ * rule), with every `@.\`-style mention drawn in the link color and
10
+ * underlined. The overlay's plain text is fully transparent, so the visible
11
+ * glyphs still come from the core backdrop; mention spans paint on top at the
12
+ * identical position (same metrics => same layout), which shows the mention as
13
+ * a real hyperlink inside the chat input.
14
+ *
15
+ * The overlay is appended directly to the core `.grow` container (a sibling
16
+ * of the backdrop/mirror) — React never manages nodes it did not create, and
17
+ * the overlay is `position:absolute;inset:0` so it always tracks the input
18
+ * box, including scroll inside `[data-input-scroll]`. It re-syncs whenever the
19
+ * core `[data-input-mirror]` text changes (a React-written text node, so it
20
+ * updates for typing AND programmatic inserts such as the core `@` menu or
21
+ * this plugin's own insertIntoComposer).
22
+ *
23
+ * Interaction: Ctrl/Cmd+click inside the composer textarea opens the mention
24
+ * under the caret in the workbench preview (the visible link is a decoration;
25
+ * the real input gains the click), mirroring the rendered-message linkifier.
26
+ */
27
+ import { openMention } from './composer'
28
+ import { findMentions } from './mentions'
29
+
30
+ /** One-time install guard (the client bundle re-applies on HMR). */
31
+ let installed = false
32
+
33
+ /** Style-tag guard (the bundle may re-apply on HMR). */
34
+ let styleInstalled = false
35
+
36
+ const OVERLAY_CSS = [
37
+ // Layout metrics must match the core rule for `.input,.mirror,.backdrop`
38
+ // exactly, so the overlay's invisible text wraps identically to the visible
39
+ // backdrop text and mentions paint at the right place.
40
+ '[data-wb-composer-mention-overlay]{position:absolute;inset:0;overflow:hidden;pointer-events:none;box-sizing:border-box;font-family:var(--dsw-font-family);font-size:inherit;line-height:inherit;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;padding:4px 12px 0 16px;color:transparent}',
41
+ '[data-wb-composer-mention-overlay][hidden]{display:none}',
42
+ '.dswb-composer-mention{color:var(--dsw-alias-state-business-primary);-webkit-text-fill-color:var(--dsw-alias-state-business-primary);text-decoration:underline;text-underline-offset:2px}',
43
+ ].join('')
44
+
45
+ function installStyle(): void {
46
+ if (styleInstalled || typeof document === 'undefined') return
47
+ styleInstalled = true
48
+ const tagId = 'dsh-plugin-workbench/composer-mention.module.css'
49
+ if (document.querySelector(`style[data-plugin-css=${JSON.stringify(tagId)}]`) === null) {
50
+ const tag = document.createElement('style')
51
+ tag.dataset.plugin = 'dsh-plugin-workbench'
52
+ tag.dataset.pluginCss = tagId
53
+ tag.textContent = OVERLAY_CSS
54
+ document.head.appendChild(tag)
55
+ }
56
+ }
57
+
58
+ /** The overlay element for the currently visible composer, or null. */
59
+ let overlayEl: HTMLElement | null = null
60
+
61
+ /** Last draft rendered into the overlay (avoids re-rendering on own mutations). */
62
+ let lastDraft: string | null = null
63
+
64
+ /** Create (once per composer instance) and return the overlay inside `.grow`. */
65
+ function ensureOverlay(): HTMLElement | null {
66
+ const seat = document.querySelector('[data-composer-seat]')
67
+ if (seat === null) return null
68
+ const mirror = seat.querySelector<HTMLElement>('[data-input-mirror]')
69
+ const grow = mirror?.parentElement
70
+ if (grow === null || grow === undefined) return null
71
+ let overlay = grow.querySelector<HTMLElement>('[data-wb-composer-mention-overlay]')
72
+ if (overlay === null) {
73
+ overlay = document.createElement('div')
74
+ overlay.dataset.wbComposerMentionOverlay = ''
75
+ overlay.setAttribute('aria-hidden', 'true')
76
+ grow.appendChild(overlay)
77
+ }
78
+ if (overlayEl !== overlay) {
79
+ overlayEl = overlay
80
+ lastDraft = null
81
+ }
82
+ return overlay
83
+ }
84
+
85
+ /** Render the (transparent) draft with mention spans into the overlay. */
86
+ function render(overlay: HTMLElement, draft: string): void {
87
+ const hits = findMentions(draft)
88
+ if (hits.length === 0) {
89
+ overlay.hidden = true
90
+ return
91
+ }
92
+ overlay.hidden = false
93
+ const frag = document.createDocumentFragment()
94
+ let cursor = 0
95
+ for (const hit of hits) {
96
+ if (hit.start > cursor) frag.appendChild(document.createTextNode(draft.slice(cursor, hit.start)))
97
+ const span = document.createElement('span')
98
+ span.className = 'dswb-composer-mention'
99
+ span.dataset.wbMention = hit.mention
100
+ span.textContent = draft.slice(hit.start, hit.end)
101
+ frag.appendChild(span)
102
+ cursor = hit.end
103
+ }
104
+ if (cursor < draft.length) frag.appendChild(document.createTextNode(draft.slice(cursor)))
105
+ overlay.replaceChildren(frag)
106
+ }
107
+
108
+ /** Re-sync the overlay with the composer draft (cheap no-op when unchanged). */
109
+ function sync(): void {
110
+ const seat = document.querySelector('[data-composer-seat]')
111
+ if (seat === null) return
112
+ // Skip the hero workspace picker / disabled states — only a usable input gets decorated.
113
+ if (seat.querySelector('textarea:not([disabled]):not([readonly])') === null) return
114
+ const mirror = seat.querySelector<HTMLElement>('[data-input-mirror]')
115
+ const overlay = ensureOverlay()
116
+ if (mirror === null || overlay === null) return
117
+ // The mirror renders `${draft}\n`; strip exactly that trailing newline.
118
+ const draft = (mirror.textContent ?? '').replace(/\n$/, '')
119
+ if (draft === lastDraft) return
120
+ lastDraft = draft
121
+ render(overlay, draft)
122
+ }
123
+
124
+ /** Ctrl/Cmd+click on the composer: open the mention under the caret. */
125
+ function onDocumentClick(e: MouseEvent): void {
126
+ if (!(e.ctrlKey || e.metaKey)) return
127
+ const target = e.target
128
+ if (!(target instanceof HTMLTextAreaElement)) return
129
+ if (target.closest('[data-composer-seat]') === null) return
130
+ // The caret has already moved to the clicked character when click fires.
131
+ const pos = target.selectionStart
132
+ const draft = target.value
133
+ const hits = findMentions(draft)
134
+ let hit = hits.find((h) => pos >= h.start && pos <= h.end)
135
+ if (hit === undefined) hit = hits.find((h) => pos - 1 >= h.start && pos - 1 < h.end)
136
+ if (hit === undefined) return
137
+ e.preventDefault()
138
+ e.stopPropagation()
139
+ openMention(hit.mention)
140
+ }
141
+
142
+ /** Start the composer mention overlay + Ctrl+click opener (idempotent). */
143
+ export function installComposerMentions(): () => void {
144
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => undefined
145
+ if (installed) return () => undefined
146
+ installed = true
147
+ installStyle()
148
+ document.addEventListener('click', onDocumentClick)
149
+ let pending = false
150
+ const scan = (): void => {
151
+ pending = false
152
+ sync()
153
+ }
154
+ const observer = new MutationObserver(() => {
155
+ if (pending) return
156
+ pending = true
157
+ requestAnimationFrame(scan)
158
+ })
159
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true })
160
+ scan()
161
+ return () => {
162
+ observer.disconnect()
163
+ document.removeEventListener('click', onDocumentClick)
164
+ installed = false
165
+ }
166
+ }
@@ -13,6 +13,7 @@ import { FilePreview } from './FilePreview'
13
13
  import { NS, zh, en } from './locales'
14
14
  import { installComposerDrops } from './composer'
15
15
  import { installMentionLinkifier } from './mentions'
16
+ import { installComposerMentions } from './composerMentions'
16
17
 
17
18
  const CHANNEL = '/dsh-plugin-files'
18
19
 
@@ -22,9 +23,11 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection'
22
23
  export function apply(ctx: Context): void {
23
24
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'files-explorer: dictionaries')
24
25
 
25
- // Drag-to-chat + @-mention linkifier: DOM-level integrations, one-time
26
- // install (module guards make re-applies on HMR idempotent).
26
+ // Drag-to-chat, @-mention linkifier, and composer mention hyperlinks:
27
+ // DOM-level integrations, one-time install (module guards make re-applies on
28
+ // HMR idempotent).
27
29
  installComposerDrops()
30
+ installComposerMentions()
28
31
  ctx.effect(() => installMentionLinkifier(), 'files-explorer: @mention linkifier')
29
32
 
30
33
  const listDir = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'list', { path }, signal))
@@ -18,7 +18,7 @@ export const zh = {
18
18
  'tab.expand': '弹出文件详情',
19
19
  'tab.diskChanged': '文件已在磁盘上被修改,点击重新加载(会放弃未保存的编辑)',
20
20
  'menu.open': '打开预览',
21
- 'menu.atFile': '@ 在消息中引用',
21
+ 'menu.atFile': '@.\ 在消息中引用',
22
22
  'menu.newFile': '新建文件',
23
23
  'menu.newFolder': '新建文件夹',
24
24
  'menu.rename': '重命名',
@@ -79,7 +79,7 @@ export const en: Record<FilesKey, string> = {
79
79
  'tab.expand': 'Expand file details',
80
80
  'tab.diskChanged': 'File changed on disk — click to reload (discards unsaved edits)',
81
81
  'menu.open': 'Open preview',
82
- 'menu.atFile': '@ Mention in message',
82
+ 'menu.atFile': '@.\ Mention in message',
83
83
  'menu.newFile': 'New File',
84
84
  'menu.newFolder': 'New Folder',
85
85
  'menu.rename': 'Rename',
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * @-mention linkifier for the conversation.
3
3
  *
4
- * The workbench inserts `@<relative-workspace-path>` into the composer (menu
5
- * gesture "在消息中引用"), and this module makes the mention VISIBLE as a
6
- * hyperlink once the message is rendered: any `@` followed by a token that
7
- * matches the mention grammar is wrapped in an anchor, and clicking it opens
8
- * the file in the workbench preview.
4
+ * The workbench inserts `@.\<relative-workspace-path>` into the composer (menu
5
+ * gesture "在消息中引用"; the `.\` prefix marks a workspace-relative path), and
6
+ * this module makes the mention VISIBLE as a hyperlink once the message is
7
+ * rendered: any `@`-prefixed token that matches the mention grammar is wrapped
8
+ * in an anchor, and clicking it opens the file in the workbench preview.
9
9
  *
10
10
  * Grammar (anything else stays plain text with no special meaning):
11
11
  * - `@` must sit at a token boundary (start of text, whitespace, or
@@ -14,8 +14,12 @@
14
14
  * - the token is the longest run of non-whitespace, non-`@` characters,
15
15
  * with trailing sentence punctuation trimmed (`.。 ,, ;; :: !! ?? ))`…);
16
16
  * - the remaining token must be a RELATIVE path (never drive-absolute or
17
- * leading-slash), and path-shaped: either contains a `/` or `\` directory
18
- * separator, or is a single segment ending in a file extension.
17
+ * leading-slash) optionally prefixed with a `.\` or `./` workspace
18
+ * marker and path-shaped: either contains a `/` or `\` directory
19
+ * separator, or is a single segment ending in a file extension;
20
+ * - a path containing whitespace may instead use the quoted `@"token"` form
21
+ * (the workbench writes it when the rel path has spaces, e.g.
22
+ * `@"\.\my plan.md"`).
19
23
  *
20
24
  * Scanning mirrors the table-zoom enhancer: a MutationObserver on
21
25
  * `document.body`, rAF-coalesced, walks the text nodes of every
@@ -28,6 +32,9 @@ import { openMention } from './composer'
28
32
  /** Mention pattern: `@` + token (no whitespace, no embedded `@`). */
29
33
  const MENTION_RE = /@([^\s@]+)/g
30
34
 
35
+ /** Quoted mention pattern: `@"token"` — used when the path contains whitespace. */
36
+ const QUOTED_MENTION_RE = /@"([^"@]+)"/g
37
+
31
38
  /** Trailing characters trimmed from a mention token before validation. */
32
39
  const TRAILING = new Set(['.', ',', ';', ':', '!', '?', '。', ',', ';', ':', '!', '?', ')', ')', ']', '】', '}', '》', '」', '』', '"', "'"])
33
40
 
@@ -51,9 +58,10 @@ export function isMentionToken(token: string): boolean {
51
58
  if (token.startsWith('/') || token.startsWith('\\')) return false
52
59
  if (token.startsWith('..')) return false
53
60
  // Path-shaped: a directory separator anywhere, or a single file segment
54
- // with an extension.
61
+ // with an extension. Spaces are allowed in the extension part — unquoted
62
+ // tokens never contain whitespace anyway, but `@"..."` paths may.
55
63
  if (token.includes('/') || token.includes('\\')) return true
56
- return /^[^\\/]+\.[A-Za-z0-9_][A-Za-z0-9._~+-]*$/.test(token)
64
+ return /^[^\\/]+\.[A-Za-z0-9_][A-Za-z0-9._~+ -]*$/.test(token)
57
65
  }
58
66
 
59
67
  /** Trim trailing sentence punctuation from a raw mention token. */
@@ -68,7 +76,9 @@ export function trimMentionToken(raw: string): string {
68
76
  /**
69
77
  * Extract every valid mention from `text` as [start, end, mention] ranges.
70
78
  * `end` covers `@` + the TRIMMED token (trailing punctuation stays outside the
71
- * link). Pure and testable: the DOM walk uses it and then splits the text node.
79
+ * link). For the quoted `@"..."` form the span covers `@"..."` including both
80
+ * quotes, while `mention` carries the inner path (used for resolution).
81
+ * Pure and testable: the DOM walk uses it and then splits the text node.
72
82
  */
73
83
  export function findMentions(text: string): Array<{ start: number; end: number; mention: string }> {
74
84
  const hits: Array<{ start: number; end: number; mention: string }> = []
@@ -82,7 +92,30 @@ export function findMentions(text: string): Array<{ start: number; end: number;
82
92
  if (!isMentionToken(token)) continue
83
93
  hits.push({ start: at, end: at + 1 + token.length, mention: token })
84
94
  }
85
- return hits
95
+ QUOTED_MENTION_RE.lastIndex = 0
96
+ let quoted: RegExpExecArray | null
97
+ while ((quoted = QUOTED_MENTION_RE.exec(text)) !== null) {
98
+ const at = quoted.index
99
+ const inner = quoted[1]
100
+ if (!isBoundaryBefore(at > 0 ? text[at - 1] : undefined)) continue
101
+ if (!isMentionToken(inner)) continue
102
+ hits.push({ start: at, end: at + inner.length + 3, mention: inner })
103
+ }
104
+ // Deterministic order, and a quoted span that starts on the same `@` as a
105
+ // plain one shadows that plain hit (plain hits on `@"` tokens are invalid
106
+ // and filtered out already, so this only guards future grammar regressions).
107
+ hits.sort((a, b) => a.start - b.start || a.end - b.end)
108
+ const merged: Array<{ start: number; end: number; mention: string }> = []
109
+ for (const hit of hits) {
110
+ const prev = merged[merged.length - 1]
111
+ if (prev !== undefined && hit.start === prev.start) {
112
+ merged[merged.length - 1] = hit
113
+ continue
114
+ }
115
+ if (prev !== undefined && hit.start < prev.end) continue
116
+ merged.push(hit)
117
+ }
118
+ return merged
86
119
  }
87
120
 
88
121
  /** Containers whose text is never linkified (code, existing links, overlays). */