dsh-coding-sidebar 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +2 -2
  2. package/lib/client-editor.js +436 -227
  3. package/lib/client-registry.js +627 -7391
  4. package/lib/client-terminal.js +234 -182
  5. package/lib/client.js +634 -7398
  6. package/lib/index.js +367 -67
  7. package/lib/types/agent-pty.d.ts +62 -4
  8. package/lib/types/bundle-route.d.ts +1 -1
  9. package/lib/types/client/EditorHost.d.ts +1 -1
  10. package/lib/types/client/FileTree.d.ts +2 -2
  11. package/lib/types/client/TreePanel.d.ts +1 -1
  12. package/lib/types/client/chunk-loader.d.ts +1 -1
  13. package/lib/types/client/chunks/locale.d.ts +3 -0
  14. package/lib/types/client/conversation-draft.d.ts +85 -4
  15. package/lib/types/client/locales.d.ts +1 -20
  16. package/lib/types/client/selection-popup.d.ts +58 -0
  17. package/lib/types/client/service.d.ts +1 -1
  18. package/lib/types/client/terminal-font.d.ts +25 -2
  19. package/lib/types/context-types.d.ts +3 -1
  20. package/lib/types/index.d.ts +9 -0
  21. package/lib/types/prefs-shared.d.ts +7 -5
  22. package/lib/types/pty-manager.d.ts +53 -0
  23. package/lib/types/wire.d.ts +6 -2
  24. package/package.json +1 -1
  25. package/src/agent-pty.ts +221 -33
  26. package/src/bundle-route.ts +1 -1
  27. package/src/client/EditorHost.tsx +1 -1
  28. package/src/client/FileTree.tsx +4 -4
  29. package/src/client/Sidebar.tsx +41 -22
  30. package/src/client/TerminalView.tsx +13 -0
  31. package/src/client/TextEditor.tsx +27 -45
  32. package/src/client/TreePanel.tsx +16 -1
  33. package/src/client/chunk-loader.ts +1 -1
  34. package/src/client/chunks/locale.tsx +60 -0
  35. package/src/client/conversation-draft.ts +233 -7
  36. package/src/client/index.tsx +19 -8
  37. package/src/client/locales-ar.ts +5 -4
  38. package/src/client/locales-de.ts +5 -4
  39. package/src/client/locales-fr.ts +5 -4
  40. package/src/client/locales-hi.ts +5 -4
  41. package/src/client/locales-id.ts +5 -4
  42. package/src/client/locales-it.ts +5 -4
  43. package/src/client/locales-ja.ts +5 -4
  44. package/src/client/locales-ko.ts +5 -4
  45. package/src/client/locales-nl.ts +5 -4
  46. package/src/client/locales-pl.ts +5 -4
  47. package/src/client/locales-pt.ts +5 -4
  48. package/src/client/locales-ru.ts +5 -4
  49. package/src/client/locales-sv.ts +5 -4
  50. package/src/client/locales-th.ts +5 -4
  51. package/src/client/locales-tr.ts +5 -4
  52. package/src/client/locales-vi.ts +5 -4
  53. package/src/client/locales-zh-HK.ts +5 -4
  54. package/src/client/locales-zh-MO.ts +5 -4
  55. package/src/client/locales-zh-TW.ts +5 -4
  56. package/src/client/locales.ts +17 -51
  57. package/src/client/selection-popup.ts +155 -0
  58. package/src/client/service.ts +1 -1
  59. package/src/client/state.ts +14 -7
  60. package/src/client/terminal-font.ts +34 -3
  61. package/src/context-types.ts +3 -2
  62. package/src/git.ts +17 -1
  63. package/src/index.ts +39 -6
  64. package/src/open-external.ts +3 -4
  65. package/src/prefs-shared.ts +7 -5
  66. package/src/pty-manager.ts +176 -5
  67. package/src/sidechat-routes.ts +13 -1
  68. package/src/tools.ts +24 -6
  69. package/src/wire.ts +3 -0
@@ -26,6 +26,7 @@ import { languageForPath } from './lang.ts'
26
26
  import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
27
27
  import { isDarkScheme, subscribeColorScheme } from './theme.ts'
28
28
  import { appendToDraft } from './conversation-draft.ts'
29
+ import { useSelectionPopup } from './selection-popup.ts'
29
30
  import { buildSelectionInsert } from './selection-payload.ts'
30
31
  import { t } from './locales.ts'
31
32
  import type { EditorToolbarControls, EditorToolbarState } from './service.ts'
@@ -50,13 +51,6 @@ export interface TextEditorProps {
50
51
  onToolbarControls?: (controls: EditorToolbarControls | null) => void
51
52
  }
52
53
 
53
- /** The floating "add to conversation" action: payload + viewport anchor. */
54
- interface SelectionPopup {
55
- insert: string
56
- left: number
57
- top: number
58
- }
59
-
60
54
  export function TextEditor(props: TextEditorProps) {
61
55
  const { ctx, scope, path, content, truncated } = props
62
56
  const [dirty, setDirty] = useState(false)
@@ -68,34 +62,21 @@ export function TextEditor(props: TextEditorProps) {
68
62
  const themeCompRef = useRef<CmThemeCompartment | null>(null)
69
63
  /** The app's resolved color scheme; the editor re-themes in place on flips. */
70
64
  const [dark, setDark] = useState(() => isDarkScheme())
71
- /** The floating "add to conversation" popup (viewport-anchored; null = hidden). */
72
- const [popup, setPopup] = useState<SelectionPopup | null>(null)
73
- /** Live mirror of the popup state for click-time reads (no re-render race). */
74
- const popupRef = useRef<SelectionPopup | null>(null)
75
-
76
- const hidePopup = (): void => {
77
- popupRef.current = null
78
- setPopup(null)
79
- }
80
-
81
- /** Anchor the popup above the selection center; clamp inside the viewport. */
82
- const showPopup = (insert: string, left: number, top: number): void => {
83
- const next: SelectionPopup = {
84
- insert,
85
- left: Math.min(Math.max(left, 80), window.innerWidth - 80),
86
- top,
87
- }
88
- popupRef.current = next
89
- setPopup(next)
90
- }
91
-
92
- /** The popup button's click: insert the stored payload into the draft. */
93
- const commitPopup = (): void => {
94
- const current = popupRef.current
95
- if (current === null) return
96
- appendToDraft(ctx, scope.sessionId, current.insert)
97
- hidePopup()
98
- }
65
+ /**
66
+ * The floating "add to conversation" popup (viewport-anchored; null =
67
+ * hidden). The hook owns show/hide/commit plus the global dismissal
68
+ * listeners (outside mousedown, Escape, hidden tab/window, and the editor
69
+ * surface leaving the viewport — the tab-switch/panel-collapse paths have
70
+ * no DOM events of their own) see selection-popup.ts.
71
+ */
72
+ const selectionPopup = useSelectionPopup({
73
+ onCommit: (insert) => { appendToDraft(ctx, scope.sessionId, insert) },
74
+ // The surface that must stay on screen: the CodeMirror host. This
75
+ // component has no preview surface (file preview is retired); without
76
+ // the geometry signal a tab switch (display:none) or a panel collapse
77
+ // (translated off-screen) would leave the fixed portaled button behind.
78
+ getSurface: () => hostRef.current,
79
+ })
99
80
 
100
81
  useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
101
82
 
@@ -103,7 +84,7 @@ export function TextEditor(props: TextEditorProps) {
103
84
  useEffect(() => {
104
85
  setDirty(false)
105
86
  setSaveState('idle')
106
- hidePopup()
87
+ selectionPopup.hide()
107
88
  }, [content])
108
89
 
109
90
  // Create the CodeMirror editor once the content is loaded. The view owns
@@ -149,33 +130,33 @@ export function TextEditor(props: TextEditorProps) {
149
130
  // selection and hides it too.
150
131
  CodeMirrorView.updateListener.of((update) => {
151
132
  if (update.geometryChanged || update.viewportChanged) {
152
- hidePopup()
133
+ selectionPopup.hide()
153
134
  return
154
135
  }
155
136
  if (!update.view.hasFocus) {
156
- hidePopup()
137
+ selectionPopup.hide()
157
138
  return
158
139
  }
159
140
  if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
160
141
  const sel = update.state.selection.main
161
142
  if (sel.empty) {
162
- hidePopup()
143
+ selectionPopup.hide()
163
144
  return
164
145
  }
165
146
  const text = update.state.sliceDoc(sel.from, sel.to)
166
147
  if (text.trim() === '') {
167
- hidePopup()
148
+ selectionPopup.hide()
168
149
  return
169
150
  }
170
151
  // Page coordinates (the document root may scroll); the popup is
171
152
  // position:fixed, so convert to viewport coordinates.
172
153
  const rect = update.view.coordsAtPos(sel.head)
173
154
  if (rect === null) {
174
- hidePopup()
155
+ selectionPopup.hide()
175
156
  return
176
157
  }
177
158
  const doc = update.state.doc
178
- showPopup(
159
+ selectionPopup.show(
179
160
  buildSelectionInsert(path, scope.cwd, {
180
161
  start: doc.lineAt(sel.from).number,
181
162
  end: doc.lineAt(sel.to).number,
@@ -272,15 +253,16 @@ export function TextEditor(props: TextEditorProps) {
272
253
  <div className={css.editorCm} ref={hostRef} />
273
254
  </>
274
255
  )}
275
- {popup !== null && createPortal(
256
+ {selectionPopup.popup !== null && createPortal(
276
257
  <button
277
258
  type="button"
259
+ ref={selectionPopup.buttonRef}
278
260
  className={css.selectionPopup}
279
- style={{ left: popup.left, top: popup.top }}
261
+ style={{ left: selectionPopup.popup.left, top: selectionPopup.popup.top }}
280
262
  // Keep the selection (and CodeMirror focus) alive until the click
281
263
  // commits — without this the popup unmounts before click lands.
282
264
  onMouseDown={(event) => { event.preventDefault() }}
283
- onClick={commitPopup}
265
+ onClick={selectionPopup.commit}
284
266
  >
285
267
  {t('addToConversation')}
286
268
  </button>,
@@ -59,7 +59,7 @@ export function TreePanel(props: {
59
59
  openWithSsh?: boolean
60
60
  onOpenWith?: (targetId: string, path: string) => void
61
61
  onToggleOpenWithPin?: (targetId: string) => void
62
- onReferenceFile: (path: string) => void
62
+ onReferenceFile: (path: string, isDir: boolean) => void
63
63
  /** Full-window presentation: the panel fills its host instead of docking
64
64
  * at a fixed width. */
65
65
  full?: boolean
@@ -69,6 +69,21 @@ export function TreePanel(props: {
69
69
  const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
70
70
  const [error, setError] = useState<string | null>(null)
71
71
  const [refreshTick, setRefreshTick] = useState(0)
72
+
73
+ // The tree caches loaded directories per refresh tick, so content changed
74
+ // outside DSH (another editor, a sync tool) stays stale until the manual
75
+ // refresh click. Re-focusing the window bumps the tick automatically, and
76
+ // integrations can force a refresh by dispatching a bubbling
77
+ // `dsh-sidebar:refresh-files` event on `window`.
78
+ useEffect(() => {
79
+ const bump = (): void => { setRefreshTick(tick => tick + 1) }
80
+ window.addEventListener('focus', bump)
81
+ window.addEventListener('dsh-sidebar:refresh-files', bump)
82
+ return () => {
83
+ window.removeEventListener('focus', bump)
84
+ window.removeEventListener('dsh-sidebar:refresh-files', bump)
85
+ }
86
+ }, [])
72
87
  /** One-line upload status under the search row ('' hides the hint). */
73
88
  const [uploadStatus, setUploadStatus] = useState('')
74
89
  /** Whether the status line is a failure/cancel (error color, stays visible). */
@@ -48,7 +48,7 @@
48
48
  * client.js); an edit that does land while a core HMR happens is caught by
49
49
  * the ETag comparison on the next activation.
50
50
  */
51
- export type ChunkName = 'terminal' | 'editor'
51
+ export type ChunkName = 'terminal' | 'editor' | 'locale'
52
52
 
53
53
  /** The module exports a chunk factory provides (namespace-ish record). */
54
54
  export type ChunkExports = Record<string, unknown>
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Lazy chunk entry: the 19 non-zh/en dictionaries (ja/de/fr/…/zh-HK/MO/TW —
3
+ * ~640KB of source). The sidebar's own `t()` only ever consults zh/en plus
4
+ * the better-locale override store, so these dicts are needed ONLY when
5
+ * @huanlin/dsh-plugin-better-locale is installed: they register into its
6
+ * override store from there (see the client apply's better-locale
7
+ * integration). Built as `lib/client-locale.js` and fetched from the
8
+ * plugin's /sidebar/bundle route on first need — never import this module
9
+ * from the core bundle: it would drag every language back into the startup
10
+ * path.
11
+ */
12
+ import { ja } from '../locales-ja.ts'
13
+ import { de } from '../locales-de.ts'
14
+ import { fr } from '../locales-fr.ts'
15
+ import { pt } from '../locales-pt.ts'
16
+ import { ko } from '../locales-ko.ts'
17
+ import { ar } from '../locales-ar.ts'
18
+ import { hi } from '../locales-hi.ts'
19
+ import { id } from '../locales-id.ts'
20
+ import { tr } from '../locales-tr.ts'
21
+ import { vi } from '../locales-vi.ts'
22
+ import { th } from '../locales-th.ts'
23
+ import { ru } from '../locales-ru.ts'
24
+ import { it } from '../locales-it.ts'
25
+ import { nl } from '../locales-nl.ts'
26
+ import { sv } from '../locales-sv.ts'
27
+ import { pl } from '../locales-pl.ts'
28
+ import { zhHK } from '../locales-zh-HK.ts'
29
+ import { zhTW } from '../locales-zh-TW.ts'
30
+ import { zhMO } from '../locales-zh-MO.ts'
31
+ import type { CopyKey } from '../locales.ts'
32
+
33
+ /** Key-set check against zh (type-only import — erased before bundling, so
34
+ * this adds no runtime tie to the core bundle): a dictionary missing or
35
+ * adding a key fails the build instead of silently falling back to en. */
36
+ const checked = (dict: Record<CopyKey, string>): Record<string, string> => dict
37
+
38
+ /** Every non-zh/en dictionary keyed by its override id, ready for the
39
+ * better-locale store's `register(ns, dicts)`. */
40
+ export const localeDicts: Record<string, Record<string, string>> = {
41
+ ja: checked(ja),
42
+ de: checked(de),
43
+ fr: checked(fr),
44
+ pt: checked(pt),
45
+ ko: checked(ko),
46
+ ar: checked(ar),
47
+ hi: checked(hi),
48
+ id: checked(id),
49
+ tr: checked(tr),
50
+ vi: checked(vi),
51
+ th: checked(th),
52
+ ru: checked(ru),
53
+ it: checked(it),
54
+ nl: checked(nl),
55
+ sv: checked(sv),
56
+ pl: checked(pl),
57
+ 'zh-HK': checked(zhHK),
58
+ 'zh-TW': checked(zhTW),
59
+ 'zh-MO': checked(zhMO),
60
+ }
@@ -1,29 +1,255 @@
1
1
  /**
2
- * Append text to the current session's composer draft through the
2
+ * Insert text into the current session's composer draft through the
3
3
  * conversation service — the shared path behind the explorer's @-reference
4
4
  * button and the viewer selection popup. The service is resolved lazily
5
5
  * through `ctx.get` (the inject-free read the app's own plugins use); a
6
6
  * missing service or scope degrades to a logged no-op, never a crash.
7
+ *
8
+ * File references additionally use DSH's own structured insert event
9
+ * (`slash/input-insert-reference`, see `insertFileReference`) instead of
10
+ * plain draft text: the native `@file` picker emits this event, and the
11
+ * conversation input machine mints one occurrence whose chip covers the
12
+ * whole reference. Plain text `@folder/file.ts` only ever gets DSH's
13
+ * folder-ref decoration (`@folder/`) — the file name stays undecorated — so
14
+ * plain append is the fallback, not the primary path, for files.
15
+ *
16
+ * Insert position (upstream issue #425): the draft store only exposes the
17
+ * whole string (`getSnapshot().draft` + `setDraft(text)`) — there is no
18
+ * caret API on the conversation service. The composer's `<textarea>` keeps
19
+ * its last selection even while unfocused, so the live caret is probed from
20
+ * the DOM (guarded by a value-sync check), and the text is spliced at that
21
+ * position, replacing any live selection — with whitespace-aware joins, an
22
+ * insert into the middle of a sentence keeps single-space separation like
23
+ * the append path. An unknown/stale caret falls back to appending at the
24
+ * end (the pre-fix behavior).
25
+ *
26
+ * The caret is also *restored* after the insert: committing a programmatic
27
+ * draft change resets the controlled textarea's caret (observed landing at
28
+ * the start of the value), which would make every later insert probe the
29
+ * reset position and drift the stack (A|B + C + D ended up as |DACB). The
30
+ * placement (`placeComposerCaretAfterInsert`) puts the caret right after the
31
+ * inserted text once the value commit lands — the index accounts for the
32
+ * separating space on the left, so stacked inserts stay at their running
33
+ * position (A|B + C + D → ACD|B).
7
34
  */
8
35
  import type { Context, SidebarConversation } from '../context-types.ts'
9
36
 
37
+ /** A resolved composer caret/selection in draft coordinates. */
38
+ export interface DraftCaret {
39
+ start: number
40
+ end: number
41
+ }
42
+
10
43
  /**
11
- * Append `text` to the session's composer draft (space-separated, like the
12
- * @-mentions). Returns false and logs when the conversation service or
13
- * the session scope is unavailable.
44
+ * The spliced draft plus the caret index (in that draft) right after the
45
+ * inserted textthe left-side separating space shifts the caret by one,
46
+ * which naive `start + text.length` misses (it would land before the tail).
47
+ */
48
+ interface SpliceResult {
49
+ draft: string
50
+ caretAfter: number
51
+ }
52
+
53
+ /**
54
+ * Splice `text` into `draft` at `caret` (replacing any live selection) with
55
+ * whitespace-aware joins and report the caret position right after the
56
+ * inserted text. `caret === null` (position unknown) appends at the end,
57
+ * exactly like the original behavior.
58
+ */
59
+ function spliceInsert(draft: string, text: string, caret: DraftCaret | null): SpliceResult {
60
+ if (caret === null || draft === '') {
61
+ const next = draft.trim() === '' ? text : `${draft} ${text}`
62
+ return { draft: next, caretAfter: next.length }
63
+ }
64
+ const prefix = draft.slice(0, caret.start)
65
+ const suffix = draft.slice(caret.end)
66
+ if (prefix === '' && suffix === '') return { draft: text, caretAfter: text.length }
67
+ // One separating space, but never doubled against adjacent whitespace
68
+ // (or the string edges) — mirrors how typing in the middle of a sentence
69
+ // behaves.
70
+ const left = prefix === '' || /\s$/.test(prefix) ? '' : ' '
71
+ const right = suffix === '' || /^\s/.test(suffix) ? '' : ' '
72
+ return {
73
+ draft: `${prefix}${left}${text}${right}${suffix}`,
74
+ caretAfter: prefix.length + left.length + text.length,
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The spliced draft string (see {@link spliceInsert}); pure string math.
80
+ */
81
+ export function insertAtCaret(draft: string, text: string, caret: DraftCaret | null): string {
82
+ return spliceInsert(draft, text, caret).draft
83
+ }
84
+
85
+ /**
86
+ * Locate the composer `<textarea>` in the conversation column: prefer the
87
+ * `data-phase`-tagged textarea (the composer's marker), falling back to any
88
+ * textarea in the column, then to a bare data-phase textarea (older host
89
+ * layouts without the column attribute). Null in jsdom-less hosts.
90
+ */
91
+ function findComposerTextarea(): HTMLTextAreaElement | null {
92
+ if (typeof document === 'undefined') return null
93
+ const column = document.querySelector('#root [data-slot="conversation"]')
94
+ const find = (scope: ParentNode): HTMLTextAreaElement | null =>
95
+ scope.querySelector('textarea[data-phase]') ?? scope.querySelector('textarea')
96
+ return column !== null
97
+ ? find(column)
98
+ : document.querySelector<HTMLTextAreaElement>('textarea[data-phase]')
99
+ }
100
+
101
+ /**
102
+ * Resolve the composer's live caret from its DOM `<textarea>`. The draft
103
+ * store has no caret API, so the sidebar reads the composed input's selection
104
+ * directly; the value-sync check (`el.value === draft`) discards stale or
105
+ * wrong-composer reads — a caret must never be applied against a draft it
106
+ * was not measured on.
107
+ *
108
+ * Returns null when the composer is missing, disabled/read-only, out of
109
+ * sync with the store draft, or has no measurable selection (odd hosts
110
+ * report null selectionStart/End).
111
+ */
112
+ export function probeComposerCaret(draft: string): DraftCaret | null {
113
+ const el = findComposerTextarea()
114
+ if (el === null || el.disabled || el.readOnly) return null
115
+ if (el.value !== draft) return null
116
+ let start = el.selectionStart
117
+ let end = el.selectionEnd
118
+ if (typeof start !== 'number' || typeof end !== 'number') return null
119
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null
120
+ start = Math.max(0, Math.min(start, draft.length))
121
+ end = Math.max(start, Math.min(end, draft.length))
122
+ return { start, end }
123
+ }
124
+
125
+ /**
126
+ * Restore the composer caret to `caretIndex` after a programmatic
127
+ * `setDraft` commit. A controlled textarea update resets the caret (React
128
+ * commits the value asynchronously and the browser moves the caret to the
129
+ * start/end), so the placement is scheduled and retried across at most two
130
+ * animation frames (setTimeout fallback), and only applied when the textarea
131
+ * still matches `expectedDraft` — a newer edit or a different composer wins
132
+ * the race untouched. The caret is clamped into the value bounds, mirroring
133
+ * how browsers clamp type-in positions.
134
+ */
135
+ export function placeComposerCaretAfterInsert(expectedDraft: string, caretIndex: number): void {
136
+ let remaining = 2
137
+ let scheduled = false
138
+ const schedule = (fn: () => void): void => {
139
+ if (scheduled) return
140
+ scheduled = true
141
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fn)
142
+ else setTimeout(fn, 0)
143
+ }
144
+ const place = (): void => {
145
+ scheduled = false
146
+ if (remaining <= 0) return
147
+ remaining -= 1
148
+ const el = findComposerTextarea()
149
+ if (el === null || el.disabled || el.readOnly) return
150
+ if (el.value !== expectedDraft) {
151
+ // The commit has not landed yet (or a competing edit won) — one more
152
+ // frame before giving up.
153
+ schedule(place)
154
+ return
155
+ }
156
+ const clamped = Math.max(0, Math.min(caretIndex, el.value.length))
157
+ el.setSelectionRange(clamped, clamped)
158
+ }
159
+ schedule(place)
160
+ }
161
+
162
+ /**
163
+ * Insert `text` into the session's composer draft at the composer's live
164
+ * caret (see {@link probeComposerCaret}), falling back to appending at the
165
+ * end when the caret cannot be resolved. Returns false — and logs — when the
166
+ * conversation service or the session scope is unavailable.
14
167
  */
15
168
  export function appendToDraft(ctx: Context, sessionId: string, text: string): boolean {
16
169
  try {
17
170
  const actx = ctx.sessions.scope(sessionId)
18
- if (actx === undefined) return false
171
+ if (actx === undefined) {
172
+ console.warn('[dsh-coding-sidebar] draft insert skipped: no session scope', sessionId)
173
+ return false
174
+ }
19
175
  const conversation = ctx.get('conversation') as SidebarConversation | undefined
20
- if (conversation === undefined) return false
176
+ if (conversation === undefined) {
177
+ console.warn('[dsh-coding-sidebar] draft insert skipped: conversation service unavailable')
178
+ return false
179
+ }
21
180
  const input = conversation.input.for(actx)
22
181
  const draft = input.state.getSnapshot().draft
23
- input.setDraft(draft.trim() === '' ? text : `${draft} ${text}`)
182
+ const caret = probeComposerCaret(draft)
183
+ const { draft: next, caretAfter } = spliceInsert(draft, text, caret)
184
+ input.setDraft(next)
185
+ // Put the caret right after the inserted text once the value commit
186
+ // lands — see the module doc for why this keeps stacked inserts at the
187
+ // running position.
188
+ placeComposerCaretAfterInsert(next, caretAfter)
24
189
  return true
25
190
  } catch (error) {
26
191
  console.warn('[dsh-coding-sidebar] draft insert failed:', error)
27
192
  return false
28
193
  }
29
194
  }
195
+
196
+ /**
197
+ * The DSH `@file` spelling for one relative path, mirroring the host grammar
198
+ * (`formatFileMention` in `@deepseek-ai/dsh-file-reference`): plain when
199
+ * there is no whitespace, quoted when there is, and `undefined` when the
200
+ * path contains a control character or an embedded quote the editor grammar
201
+ * cannot represent.
202
+ */
203
+ export function fileMention(relativePath: string): { mention: string; label: string } | undefined {
204
+ const path = relativePath.replace(/[\\/]+$/, '')
205
+ // eslint-disable-next-line no-control-regex -- rejecting control characters is the point of this guard
206
+ if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
207
+ const mention = /\s/u.test(path) ? `@"${path}"` : `@${path}`
208
+ const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
209
+ const label = at === -1 ? path : path.slice(at + 1)
210
+ return { mention, label }
211
+ }
212
+
213
+ /**
214
+ * Insert one FILE reference as a structured chip (like DSH's own `@` picker).
215
+ * The chip displays `@<basename>` but serializes to `@<relative path>` on
216
+ * send, so the reference stays a single link from trigger to basename.
217
+ *
218
+ * Directories are NOT handled here: DSH's folder grammar wants the trailing
219
+ * slash as plain text (`@dir/`) so completion can descend, which
220
+ * `appendToDraft` already covers.
221
+ */
222
+ export function insertFileReference(ctx: Context, sessionId: string, relativePath: string): boolean {
223
+ const reference = fileMention(relativePath)
224
+ if (reference === undefined) return false
225
+ try {
226
+ const actx = ctx.sessions.scope(sessionId)
227
+ if (actx === undefined) return false
228
+ const conversation = ctx.get('conversation') as SidebarConversation | undefined
229
+ if (conversation === undefined) return false
230
+ const input = conversation.input.for(actx)
231
+ const before = input.state.getSnapshot()
232
+ if (before.draftRev === undefined) return false
233
+ // The session-scope Context's typed `emit` is keyed to DSH's closed event
234
+ // map; this internal composer event is deliberately string-loose at runtime.
235
+ ;(actx as unknown as { emit(name: string, payload: unknown): void }).emit('slash/input-insert-reference', {
236
+ reference: {
237
+ source: 'reference',
238
+ ref: reference.mention,
239
+ label: reference.label,
240
+ appearance: 'file',
241
+ clipboardText: reference.mention,
242
+ },
243
+ span: {
244
+ draftRev: before.draftRev,
245
+ start: before.draft.length,
246
+ end: before.draft.length,
247
+ },
248
+ })
249
+ const after = input.state.getSnapshot()
250
+ return after.draftRev !== before.draftRev
251
+ } catch (error) {
252
+ console.warn('[dsh-coding-sidebar] file-reference insert failed:', error)
253
+ return false
254
+ }
255
+ }
@@ -25,10 +25,8 @@ import { registerSettingsNavIcon } from './settings-nav-icon.ts'
25
25
  import { loadExternalDisable, loadPrefs } from './prefs.ts'
26
26
  import { SideCardSection } from './SideCardSection.tsx'
27
27
  import { api } from './api.ts'
28
- import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en,
29
- ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
30
- zhHK, zhTW, zhMO,
31
- } from './locales.ts'
28
+ import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en } from './locales.ts'
29
+ import { loadChunk } from './chunk-loader.ts'
32
30
  import css from './sidebar.module.css'
33
31
  import './layout.css'
34
32
 
@@ -87,7 +85,12 @@ export function apply(ctx: Context): void {
87
85
  // the ja dict once the store becomes available.
88
86
  ctx.effect(() => {
89
87
  let dispose: (() => void) | undefined
88
+ // Guards the async chunk registration below: a sync() re-run (or fiber
89
+ // disposal) that lands while the chunk is still in flight must render
90
+ // that registration moot.
91
+ let generation = 0
90
92
  const sync = (): void => {
93
+ generation += 1
91
94
  dispose?.()
92
95
  dispose = undefined
93
96
  const store = ctx.get('betterLocale') as
@@ -101,10 +104,17 @@ export function apply(ctx: Context): void {
101
104
  | undefined
102
105
  attachBetterLocale(store)
103
106
  if (store !== undefined) {
104
- dispose = store.register(LOCALE_NS, {
105
- ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
106
- 'zh-HK': zhHK, 'zh-TW': zhTW, 'zh-MO': zhMO,
107
- })
107
+ // The 19 override dictionaries ride the lazy `locale` chunk: until
108
+ // it lands, the store has no betterSidebar entries and t() keeps
109
+ // the zh/en chain; the store's own revision bump on register
110
+ // re-renders the chrome once the dicts arrive.
111
+ const myGeneration = generation
112
+ void loadChunk('locale')
113
+ .then(mod => {
114
+ if (myGeneration !== generation) return
115
+ dispose = store.register(LOCALE_NS, mod.localeDicts as Record<string, Record<string, string>>)
116
+ })
117
+ .catch(() => { /* the dicts stay unregistered; the zh/en chain runs */ })
108
118
  }
109
119
  }
110
120
  // Initial check (picks up the store if better-locale activated first).
@@ -113,6 +123,7 @@ export function apply(ctx: Context): void {
113
123
  // activates with a persisted override, and when the user switches).
114
124
  const unsubscribe = ctx.locale.subscribe(sync)
115
125
  return () => {
126
+ generation += 1
116
127
  unsubscribe()
117
128
  dispose?.()
118
129
  attachBetterLocale(undefined)
@@ -89,6 +89,7 @@ export const ar: Record<string, string> = {
89
89
  terminalDepsFailed: 'فشل تحميل تبعية الطرفية node-pty',
90
90
  terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):',
91
91
  terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})',
92
+ terminalShellNotFound: 'لم يتم العثور على الصدفة المُعدَّة: {name} — تحقق من مسار الصدفة في الإعدادات → Side card → Terminal',
92
93
  preview: 'معاينة',
93
94
  edit: 'تحرير',
94
95
  refresh: 'تحديث',
@@ -214,10 +215,10 @@ export const ar: Record<string, string> = {
214
215
  settingsConflict: 'تغيّر الإعداد في نافذة أخرى — يُرجى إعادة المحاولة',
215
216
  binaryNoPreview: 'لا يمكن معاينة هذا النوع من الملفات',
216
217
  downloadToView: 'تنزيل للعرض',
217
- settingsSubagentTitle: 'فتح صفحة المهام تلقائياً عند ظهور وكيل فرعي',
218
- settingsSubagentDesc: 'توسيع البطاقة الجانبية وفتح صفحة المهام عند توليد المحادثة الحالية لوكيل فرعي جديد؛ أوقفها للفتح يدوياً',
219
- settingsJobsTitle: 'فتح صفحة المهام الخلفية تلقائياً عند مهمة خلفية جديدة',
220
- settingsJobsDesc: 'توسيع البطاقة الجانبية وفتح صفحة المهام الخلفية عند ظهور مهمة خلفية جديدة للمحادثة الحالية (كل مهمة جديدة تُطلق ذلكأوقفها للفتح يدوياً',
218
+ settingsSubagentTitle: 'تفعيل صفحة المهام تلقائيًا عند ظهور وكيل فرعي',
219
+ settingsSubagentDesc: 'ينشّط صفحة المهام عندما تنشئ المحادثة الحالية وكيلًا فرعيًا جديدًا؛ على الشاشات الواسعة تتوسع البطاقة الجانبية أيضًا، ولا تفرض الشاشات الضيقة الدرج بملء الشاشة؛ أوقفه للفتح يدويًا',
220
+ settingsJobsTitle: 'تفعيل صفحة المهام الخلفية تلقائيًا عند مهمة خلفية جديدة',
221
+ settingsJobsDesc: 'ينشّط صفحة المهام الخلفية كلما ظهرت مهمة خلفية جديدة للمحادثة الحالية (كل مهمة جديدة تُفعّلهعلى الشاشات الواسعة تتوسع البطاقة الجانبية أيضًا، ولا تفرض الشاشات الضيقة الدرج بملء الشاشة؛ أوقفه للفتح يدويًا',
221
222
  settingsToolsTitle: 'حقن أدوات الطرفية للنموذج',
222
223
  settingsToolsDesc: 'عند التفعيل، يمكن للنموذج إنشاء وتشغيل طرفيات الشريط الجانبي عبر أدوات terminal_* الثمانية (معطّل افتراضياً)',
223
224
  settingsFontFamilyTitle: 'عائلة خط الطرفية',
@@ -74,6 +74,7 @@ export const de: Record<string, string> = {
74
74
  terminalDepsFailed: 'Die Terminal-Abhängigkeit node-pty konnte nicht geladen werden',
75
75
  terminalDepsHint: 'Führen Sie den folgenden Befehl in einem Terminal oder in cmd auf dem DSH-System aus, um dies zu beheben, und klicken Sie dann auf „Erneut versuchen“ (node-pty bleibt mit der DSH-Core-Version synchron):',
76
76
  terminalDepsProfile: ' (erkanntes Profil: {profile})',
77
+ terminalShellNotFound: 'Konfigurierte Shell nicht gefunden: {name} — Shell-Pfad unter Einstellungen → Side card → Terminal prüfen',
77
78
  preview: 'Vorschau',
78
79
  edit: 'Bearbeiten',
79
80
  refresh: 'Aktualisieren',
@@ -199,10 +200,10 @@ export const de: Record<string, string> = {
199
200
  settingsConflict: 'Die Einstellung wurde in einem anderen Fenster geändert – bitte erneut versuchen',
200
201
  binaryNoPreview: 'Dieser Dateityp kann nicht in der Vorschau angezeigt werden',
201
202
  downloadToView: 'Zum Ansehen herunterladen',
202
- settingsSubagentTitle: 'Aufgaben-Seite bei einem Subagenten automatisch öffnen',
203
- settingsSubagentDesc: 'Die Seitenkarte wird ausgeklappt und die Aufgaben-Seite geöffnet, wenn die aktuelle Unterhaltung einen neuen Subagenten erzeugt; ausschalten, um sie manuell zu öffnen',
204
- settingsJobsTitle: 'Aufgaben-Seite bei einer neuen Hintergrundaufgabe automatisch öffnen',
205
- settingsJobsDesc: 'Die Seitenkarte wird ausgeklappt und die Aufgaben-Seite geöffnet, sobald für die aktuelle Unterhaltung eine neue Hintergrundaufgabe erscheint (jede neue Aufgabe löst dies aus); ausschalten, um sie manuell zu öffnen',
203
+ settingsSubagentTitle: 'Aufgabenseite bei neuen Subagenten automatisch aktivieren',
204
+ settingsSubagentDesc: 'Aktiviert die Aufgabenseite, wenn die aktuelle Konversation einen neuen Subagenten erzeugt; auf breiten Bildschirmen wird die Seitenkarte mit ausgeklappt, schmale Bildschirme erzwingen keine Vollbild-Schublade; zum manuellen Öffnen deaktivieren',
205
+ settingsJobsTitle: 'Aufgabenseite bei neuem Hintergrundjob automatisch aktivieren',
206
+ settingsJobsDesc: 'Aktiviert die Aufgabenseite, wenn ein neuer Hintergrundjob für die aktuelle Konversation erscheint (jeder neue Job löst aus); auf breiten Bildschirmen wird die Seitenkarte mit ausgeklappt, schmale Bildschirme erzwingen keine Vollbild-Schublade; zum manuellen Öffnen deaktivieren',
206
207
  settingsToolsTitle: 'Terminal-Werkzeuge für das Modell bereitstellen',
207
208
  settingsToolsDesc: 'Wenn aktiviert, kann das Modell über die 8 terminal_*-Werkzeuge Terminale in der Seitenleiste erstellen und steuern (standardmäßig deaktiviert)',
208
209
  settingsFontFamilyTitle: 'Terminal-Schriftart',
@@ -81,6 +81,7 @@ export const fr: Record<string, string> = {
81
81
  terminalDepsFailed: 'Échec du chargement de la dépendance terminal node-pty',
82
82
  terminalDepsHint: 'Exécutez la commande suivante dans un terminal ou cmd de l’environnement DSH pour réparer, puis cliquez sur Réessayer (node-pty reste à la même version que le cœur DSH) :',
83
83
  terminalDepsProfile: ' (profil détecté : {profile})',
84
+ terminalShellNotFound: 'Shell configuré introuvable : {name} — vérifiez le chemin du shell dans Réglages → Side card → Terminal',
84
85
  preview: 'Aperçu',
85
86
  edit: 'Modifier',
86
87
  refresh: 'Actualiser',
@@ -206,10 +207,10 @@ export const fr: Record<string, string> = {
206
207
  settingsConflict: 'Les réglages ont été modifiés par une autre fenêtre, veuillez réessayer',
207
208
  binaryNoPreview: 'Ce type de fichier ne prend pas en charge l’aperçu',
208
209
  downloadToView: 'Télécharger pour consulter',
209
- settingsSubagentTitle: 'Déployer automatiquement la page de gestion des tâches à la détection d’un sous-agent',
210
- settingsSubagentDesc: 'Lorsque la session actuelle produit un nouveau sous-agent, déployer automatiquement la barre latérale et ouvrir la page de gestion des tâches ; une fois désactivé, l’ouverture est manuelle',
211
- settingsJobsTitle: 'Déployer automatiquement la page des tâches d’arrière-plan sur nouvelle tâche',
212
- settingsJobsDesc: 'Lorsque la session actuelle produit une nouvelle tâche d’arrière-plan, déployer automatiquement la barre latérale et ouvrir la page des tâches (déclenché à chaque nouvelle tâche) ; une fois désactivé, l’ouverture est manuelle',
210
+ settingsSubagentTitle: 'Activer automatiquement la page des tâches quand un sous-agent apparaît',
211
+ settingsSubagentDesc: 'Active la page des tâches quand la conversation actuelle crée un nouveau sous-agent ; sur les grands écrans la carte latérale se déploie aussi, les petits écrans ne forcent jamais le tiroir plein écran ; désactivez pour ouvrir manuellement',
212
+ settingsJobsTitle: 'Activer automatiquement la page des tâches d’arrière-plan pour une nouvelle tâche',
213
+ settingsJobsDesc: 'Active la page des tâches d’arrière-plan dès qu’une nouvelle tâche apparaît pour la conversation actuelle (chaque nouvelle tâche déclenche) ; sur les grands écrans la carte latérale se déploie aussi, les petits écrans ne forcent jamais le tiroir plein écran ; désactivez pour ouvrir manuellement',
213
214
  settingsToolsTitle: 'Injecter des outils de terminal au modèle',
214
215
  settingsToolsDesc: 'Une fois activé, le modèle peut créer et piloter des terminaux de la barre latérale via les 8 outils terminal_* (désactivé par défaut)',
215
216
  settingsFontFamilyTitle: 'Police du terminal',