@keith9681/dsh-sidebar 0.15.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.
Files changed (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/README_EN.md +0 -0
  4. package/cordis.patch.yml +49 -0
  5. package/lib/client-editor.js +34651 -0
  6. package/lib/client-mermaid.js +200629 -0
  7. package/lib/client-registry.js +13388 -0
  8. package/lib/client-terminal.js +8904 -0
  9. package/lib/client.js +13388 -0
  10. package/lib/index.js +3959 -0
  11. package/lib/invariant.js +22 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +22 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +12 -0
  19. package/lib/types/client/FileTree.d.ts +36 -0
  20. package/lib/types/client/GitView.d.ts +8 -0
  21. package/lib/types/client/OrphanedTab.d.ts +11 -0
  22. package/lib/types/client/PdfView.d.ts +6 -0
  23. package/lib/types/client/RenderBoundary.d.ts +32 -0
  24. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  25. package/lib/types/client/SideCardSection.d.ts +74 -0
  26. package/lib/types/client/SideChatView.d.ts +16 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +43 -0
  30. package/lib/types/client/TerminalView.d.ts +24 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/TreePanel.d.ts +23 -0
  33. package/lib/types/client/UploadOverlay.d.ts +20 -0
  34. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +268 -0
  36. package/lib/types/client/binary-download.d.ts +12 -0
  37. package/lib/types/client/breakpoints.d.ts +12 -0
  38. package/lib/types/client/browser.d.ts +50 -0
  39. package/lib/types/client/builtins/index.d.ts +18 -0
  40. package/lib/types/client/builtins/tabs.d.ts +11 -0
  41. package/lib/types/client/builtins/viewers.d.ts +3 -0
  42. package/lib/types/client/chunk-loader.d.ts +124 -0
  43. package/lib/types/client/chunks/editor.d.ts +9 -0
  44. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  45. package/lib/types/client/chunks/terminal.d.ts +9 -0
  46. package/lib/types/client/cm-themes.d.ts +24 -0
  47. package/lib/types/client/conversation-draft.d.ts +14 -0
  48. package/lib/types/client/desktop-env.d.ts +35 -0
  49. package/lib/types/client/editable-state.d.ts +27 -0
  50. package/lib/types/client/editor-load.d.ts +66 -0
  51. package/lib/types/client/frame-batcher.d.ts +24 -0
  52. package/lib/types/client/icons.d.ts +57 -0
  53. package/lib/types/client/image-types.d.ts +3 -0
  54. package/lib/types/client/ime-guard.d.ts +36 -0
  55. package/lib/types/client/index.d.ts +19 -0
  56. package/lib/types/client/intercept.d.ts +33 -0
  57. package/lib/types/client/lang.d.ts +12 -0
  58. package/lib/types/client/lazy-chunk.d.ts +25 -0
  59. package/lib/types/client/link-intercept.d.ts +42 -0
  60. package/lib/types/client/locales.d.ts +366 -0
  61. package/lib/types/client/mermaid-blocks.d.ts +41 -0
  62. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  63. package/lib/types/client/mermaid.d.ts +12 -0
  64. package/lib/types/client/open-when-sized.d.ts +20 -0
  65. package/lib/types/client/open-with-settings.d.ts +4 -0
  66. package/lib/types/client/open-with.d.ts +87 -0
  67. package/lib/types/client/openpath-intercept.d.ts +41 -0
  68. package/lib/types/client/paths.d.ts +35 -0
  69. package/lib/types/client/pdf-types.d.ts +2 -0
  70. package/lib/types/client/plugin-settings.d.ts +8 -0
  71. package/lib/types/client/plugins-shared.d.ts +27 -0
  72. package/lib/types/client/plugins-tabs.d.ts +3 -0
  73. package/lib/types/client/plugins-viewers.d.ts +3 -0
  74. package/lib/types/client/prefs.d.ts +41 -0
  75. package/lib/types/client/produced-files.d.ts +23 -0
  76. package/lib/types/client/selection-payload.d.ts +27 -0
  77. package/lib/types/client/service.d.ts +440 -0
  78. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  79. package/lib/types/client/shell-presets.d.ts +48 -0
  80. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  81. package/lib/types/client/split-pane.d.ts +30 -0
  82. package/lib/types/client/state.d.ts +359 -0
  83. package/lib/types/client/subagent-detect.d.ts +54 -0
  84. package/lib/types/client/subagent-jobs.d.ts +63 -0
  85. package/lib/types/client/tab-content-memo.d.ts +33 -0
  86. package/lib/types/client/terminal-font.d.ts +20 -0
  87. package/lib/types/client/theme.d.ts +45 -0
  88. package/lib/types/client/titlebar-strip.d.ts +23 -0
  89. package/lib/types/client/upload.d.ts +69 -0
  90. package/lib/types/client/wco.d.ts +47 -0
  91. package/lib/types/config.d.ts +64 -0
  92. package/lib/types/context-types.d.ts +603 -0
  93. package/lib/types/fs-operations.d.ts +28 -0
  94. package/lib/types/fs-search.d.ts +24 -0
  95. package/lib/types/fs-tree.d.ts +51 -0
  96. package/lib/types/git.d.ts +82 -0
  97. package/lib/types/html-route.d.ts +59 -0
  98. package/lib/types/index.d.ts +47 -0
  99. package/lib/types/invariant.d.ts +15 -0
  100. package/lib/types/jobs-routes.d.ts +45 -0
  101. package/lib/types/open-external.d.ts +24 -0
  102. package/lib/types/prefs-shared.d.ts +225 -0
  103. package/lib/types/pty-deps.d.ts +78 -0
  104. package/lib/types/pty-manager.d.ts +168 -0
  105. package/lib/types/sidechat-core.d.ts +172 -0
  106. package/lib/types/sidechat-routes.d.ts +30 -0
  107. package/lib/types/subagent-activity.d.ts +44 -0
  108. package/lib/types/subagent-live-route.d.ts +44 -0
  109. package/lib/types/tools.d.ts +31 -0
  110. package/lib/types/trust-fence.d.ts +24 -0
  111. package/lib/types/wire.d.ts +38 -0
  112. package/package.json +173 -0
  113. package/scripts/install.ps1 +289 -0
  114. package/scripts/install.sh +287 -0
  115. package/src/agent-pty.ts +527 -0
  116. package/src/browser-probe.ts +26 -0
  117. package/src/bundle-route.ts +129 -0
  118. package/src/client/BrowserView.tsx +253 -0
  119. package/src/client/DiffTab.tsx +110 -0
  120. package/src/client/DiffView.tsx +305 -0
  121. package/src/client/EditorHost.tsx +525 -0
  122. package/src/client/FileTree.tsx +653 -0
  123. package/src/client/GitView.tsx +558 -0
  124. package/src/client/OrphanedTab.tsx +27 -0
  125. package/src/client/PdfView.tsx +110 -0
  126. package/src/client/RenderBoundary.tsx +49 -0
  127. package/src/client/SandboxStatusBar.tsx +60 -0
  128. package/src/client/SideCardSection.module.css +960 -0
  129. package/src/client/SideCardSection.tsx +1152 -0
  130. package/src/client/SideChatView.module.css +437 -0
  131. package/src/client/SideChatView.tsx +644 -0
  132. package/src/client/Sidebar.tsx +1148 -0
  133. package/src/client/SubagentView.module.css +547 -0
  134. package/src/client/SubagentView.tsx +883 -0
  135. package/src/client/TabBar.tsx +305 -0
  136. package/src/client/TerminalView.tsx +383 -0
  137. package/src/client/TextEditor.tsx +500 -0
  138. package/src/client/TreePanel.tsx +273 -0
  139. package/src/client/UploadOverlay.tsx +62 -0
  140. package/src/client/add-plugin-modal.tsx +199 -0
  141. package/src/client/api.ts +335 -0
  142. package/src/client/binary-download.tsx +23 -0
  143. package/src/client/breakpoints.ts +51 -0
  144. package/src/client/browser.ts +119 -0
  145. package/src/client/builtins/index.ts +37 -0
  146. package/src/client/builtins/tabs.tsx +331 -0
  147. package/src/client/builtins/viewers.tsx +125 -0
  148. package/src/client/chunk-loader.ts +351 -0
  149. package/src/client/chunks/editor.tsx +9 -0
  150. package/src/client/chunks/mermaid.tsx +10 -0
  151. package/src/client/chunks/terminal.tsx +9 -0
  152. package/src/client/cm-themes.ts +129 -0
  153. package/src/client/conversation-draft.ts +29 -0
  154. package/src/client/css-modules.d.ts +5 -0
  155. package/src/client/desktop-env.ts +72 -0
  156. package/src/client/editable-state.ts +70 -0
  157. package/src/client/editor-load.ts +92 -0
  158. package/src/client/frame-batcher.ts +56 -0
  159. package/src/client/icons.tsx +161 -0
  160. package/src/client/image-types.ts +8 -0
  161. package/src/client/ime-guard.ts +47 -0
  162. package/src/client/index.tsx +369 -0
  163. package/src/client/intercept.tsx +110 -0
  164. package/src/client/lang.ts +103 -0
  165. package/src/client/layout.css +94 -0
  166. package/src/client/lazy-chunk.tsx +89 -0
  167. package/src/client/link-intercept.ts +73 -0
  168. package/src/client/locales.ts +734 -0
  169. package/src/client/mermaid-blocks.ts +110 -0
  170. package/src/client/mermaid-sanitize.ts +93 -0
  171. package/src/client/mermaid.tsx +401 -0
  172. package/src/client/open-when-sized.ts +43 -0
  173. package/src/client/open-with-settings.tsx +135 -0
  174. package/src/client/open-with.ts +221 -0
  175. package/src/client/openpath-intercept.ts +62 -0
  176. package/src/client/paths.ts +47 -0
  177. package/src/client/pdf-types.ts +4 -0
  178. package/src/client/plugin-settings.ts +43 -0
  179. package/src/client/plugins-shared.ts +29 -0
  180. package/src/client/plugins-tabs.ts +87 -0
  181. package/src/client/plugins-viewers.ts +30 -0
  182. package/src/client/prefs.ts +207 -0
  183. package/src/client/produced-files.ts +91 -0
  184. package/src/client/selection-payload.ts +86 -0
  185. package/src/client/service.ts +838 -0
  186. package/src/client/settings-nav-icon.ts +45 -0
  187. package/src/client/shell-presets.ts +83 -0
  188. package/src/client/sidebar.module.css +2577 -0
  189. package/src/client/sidechat-transcript.ts +321 -0
  190. package/src/client/split-pane.tsx +322 -0
  191. package/src/client/state.ts +1175 -0
  192. package/src/client/subagent-detect.ts +137 -0
  193. package/src/client/subagent-jobs.ts +161 -0
  194. package/src/client/tab-content-memo.ts +46 -0
  195. package/src/client/terminal-font.ts +28 -0
  196. package/src/client/theme.ts +110 -0
  197. package/src/client/titlebar-strip.ts +37 -0
  198. package/src/client/upload.ts +187 -0
  199. package/src/client/wco.ts +120 -0
  200. package/src/config.ts +155 -0
  201. package/src/context-types.ts +610 -0
  202. package/src/fs-operations.ts +99 -0
  203. package/src/fs-search.ts +86 -0
  204. package/src/fs-tree.ts +158 -0
  205. package/src/git.ts +244 -0
  206. package/src/html-route.ts +106 -0
  207. package/src/index.ts +1132 -0
  208. package/src/invariant.ts +32 -0
  209. package/src/jobs-routes.ts +257 -0
  210. package/src/open-external.ts +90 -0
  211. package/src/prefs-shared.ts +268 -0
  212. package/src/pty-deps.ts +240 -0
  213. package/src/pty-manager.ts +376 -0
  214. package/src/sidechat-core.ts +468 -0
  215. package/src/sidechat-routes.ts +344 -0
  216. package/src/subagent-activity.ts +91 -0
  217. package/src/subagent-live-route.ts +93 -0
  218. package/src/tools.ts +479 -0
  219. package/src/trust-fence.ts +77 -0
  220. package/src/wire.ts +100 -0
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Typed fetch wrapper over the /sidebar JSON API. Every call posts to
3
+ * `/sidebar/api/<method>` with the sessionId and — when known — the session's
4
+ * cwd from the client's own list summary. The host prefers its attached
5
+ * session header and uses the summary cwd only while the session is still
6
+ * hydrating at page load (a detached session would otherwise fail the
7
+ * request). Failures surface as {@link SidebarApiError} with the wire code.
8
+ */
9
+ import { encodeHtmlUrl } from '../html-route.ts'
10
+ import type { LastActivity } from '../subagent-activity.ts'
11
+ import type { SidechatThreadInfo } from '../sidechat-core.ts'
12
+ import type { BrowserProbeResult } from './browser.ts'
13
+
14
+ /** One wire failure. */
15
+ export class SidebarApiError extends Error {
16
+ constructor(
17
+ readonly code: string,
18
+ message: string,
19
+ ) {
20
+ super(message)
21
+ }
22
+ }
23
+
24
+ /** Explorer row (host fs-tree shape). */
25
+ export interface FsEntry {
26
+ name: string
27
+ path: string
28
+ isDir: boolean
29
+ hidden: boolean
30
+ /** Whether the row is a symlink; `isDir` then describes the link's target. */
31
+ isSymlink: boolean
32
+ /** For symlinks: the target is missing or unreadable (stat failed). */
33
+ broken: boolean
34
+ }
35
+
36
+ /** Git status entry (host git shape). */
37
+ export interface GitStatusEntry {
38
+ path: string
39
+ xy: string
40
+ }
41
+
42
+ /** Git status snapshot. */
43
+ export interface GitStatusResult {
44
+ isRepo: boolean
45
+ branch?: string
46
+ entries: GitStatusEntry[]
47
+ }
48
+
49
+ /** One git log row. */
50
+ export interface GitLogEntry {
51
+ /** Short hash (7+ chars, display). */
52
+ hash: string
53
+ /** Full 40-char hash (advanced operations). */
54
+ hashFull: string
55
+ subject: string
56
+ author: string
57
+ /** ISO 8601 author date (`%ai`). */
58
+ date: string
59
+ /** Ref decorations (--decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
60
+ refs: string
61
+ }
62
+
63
+ /** Text read result. */
64
+ export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
65
+ /** Binary read result (no content; images load through the media route).
66
+ * `head` carries the first bytes (base64) for viewer detect sniffing. */
67
+ export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
68
+
69
+ /**
70
+ * One jobs.output response: the output the MODEL has read so far for the
71
+ * job (replayed from the owner session's event log — the model's
72
+ * job_output cursor is never touched, so the pane can never steal the
73
+ * agent's bytes). `read` is false until the model actually called
74
+ * job_output for the job.
75
+ */
76
+ export interface JobOutputResult {
77
+ text: string
78
+ /** True when the host capped the text at its output limit. */
79
+ truncated: boolean
80
+ /** Whether the model has read the job at least once. */
81
+ read: boolean
82
+ }
83
+
84
+ /** The `subagents.live` response: running child id → latest activity. */
85
+ export type SubagentLiveResult = { live: Record<string, LastActivity> }
86
+
87
+ /** Terminal dependency status (mirror of the host's depsStatus; issue #140). */
88
+ export type TerminalDepsStatus =
89
+ | { ok: true }
90
+ | {
91
+ ok: false
92
+ /** The require-time error message (module missing, native binding broken…). */
93
+ cause: string
94
+ /** The pasteable repair command (terminal/cmd). */
95
+ command: string
96
+ /** The detected profile name (null when undetected → the command defaults to web). */
97
+ profile: string | null
98
+ /** Optional supplementary hint (fallback command only). */
99
+ note?: string
100
+ }
101
+
102
+ async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
103
+ let response: Response
104
+ try {
105
+ response = await fetch(`/sidebar/api/${method}`, {
106
+ method: 'POST',
107
+ headers: { 'content-type': 'application/json' },
108
+ body: JSON.stringify(payload),
109
+ signal,
110
+ })
111
+ } catch (error) {
112
+ throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
113
+ }
114
+ const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
115
+ = await response.json().catch(() => null)
116
+ if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
117
+ throw new SidebarApiError(
118
+ parsed?.error?.code ?? 'http',
119
+ parsed?.error?.message ?? `HTTP ${response.status}`,
120
+ )
121
+ }
122
+ return parsed.value as T
123
+ }
124
+
125
+ /**
126
+ * Upload one file to the sidebar's raw upload route: the File goes straight
127
+ * into the POST body (no JSON/base64 re-encoding — the host streams it into
128
+ * the workspace). Failure surfaces as {@link SidebarApiError} with the wire
129
+ * code, exactly like every `/sidebar/api` call. An aborted `signal` rejects
130
+ * with the DOMException as-is (the caller decides whether that is an error).
131
+ */
132
+ async function fetchUpload<T>(
133
+ scope: SessionScope,
134
+ dir: string,
135
+ relativePath: string,
136
+ body: Blob,
137
+ signal?: AbortSignal,
138
+ ): Promise<T> {
139
+ const params = new URLSearchParams({ sessionId: scope.sessionId, dir, relativePath })
140
+ if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
141
+ let response: Response
142
+ try {
143
+ response = await fetch(`/sidebar/upload?${params.toString()}`, {
144
+ method: 'POST',
145
+ headers: { 'content-type': 'application/octet-stream' },
146
+ body,
147
+ signal,
148
+ })
149
+ } catch (error) {
150
+ if (error instanceof DOMException && error.name === 'AbortError') throw error
151
+ throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
152
+ }
153
+ const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
154
+ = await response.json().catch(() => null)
155
+ if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
156
+ throw new SidebarApiError(
157
+ parsed?.error?.code ?? 'http',
158
+ parsed?.error?.message ?? `HTTP ${response.status}`,
159
+ )
160
+ }
161
+ return parsed.value as T
162
+ }
163
+
164
+ /** One request's session scope: the conversation id plus its cwd when known. */
165
+ export interface SessionScope {
166
+ sessionId: string
167
+ /** The session's working directory from the client list summary (optional). */
168
+ cwd?: string
169
+ }
170
+
171
+ /** Fold a scope into a JSON payload ({cwd} only when present). */
172
+ function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
173
+ return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...extra }
174
+ }
175
+
176
+ /** The sidebar API surface (session scope threaded through every call). */
177
+ export const api = {
178
+ sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
179
+ call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
180
+ fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
181
+ call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
182
+ /** Global recursive file-name search rooted at the session cwd (the editor
183
+ * side panel's search box); matches are cwd-relative '/'-separated paths. */
184
+ fsSearch: (scope: SessionScope, query: string, signal?: AbortSignal) =>
185
+ call<{ matches: string[]; truncated: boolean }>('fs.search', scopePayload(scope, { query }), signal),
186
+ fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
187
+ call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
188
+ fsWrite: (scope: SessionScope, path: string, content: string) =>
189
+ call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
190
+ /** Upload one file's raw bytes into `dir` (keeps the folder tree via
191
+ * `relativePath`); the host streams it under the session workspace. */
192
+ uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
193
+ fetchUpload<{ path: string; size: number }>(scope, dir, relativePath, body, signal),
194
+ gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
195
+ call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
196
+ gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
197
+ call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
198
+ gitStage: (scope: SessionScope, path?: string) =>
199
+ call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
200
+ gitUnstage: (scope: SessionScope, path?: string) =>
201
+ call<{ ok: true }>('git.unstage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
202
+ gitCommit: (scope: SessionScope, message: string) =>
203
+ call<{ ok: true }>('git.commit', scopePayload(scope, { message })),
204
+ gitBranch: (scope: SessionScope, signal?: AbortSignal) =>
205
+ call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
206
+ gitCheckout: (scope: SessionScope, branch: string) =>
207
+ call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
208
+ /** Recent commit history, lazily pageable (skip/count; defaults 0/30). */
209
+ gitLog: (scope: SessionScope, count?: number, skip?: number, signal?: AbortSignal) =>
210
+ call<GitLogEntry[]>('git.log', scopePayload(scope, {
211
+ ...(count !== undefined ? { count } : {}),
212
+ ...(skip !== undefined ? { skip } : {}),
213
+ }), signal),
214
+ /** Full patch text of one commit (diff display for the history rows). */
215
+ gitCommitDiff: (scope: SessionScope, hash: string, signal?: AbortSignal) =>
216
+ call<{ diff: string }>('git.commit-diff', scopePayload(scope, { hash }), signal),
217
+ /** Discard the worktree changes of one file (the index is untouched). */
218
+ gitDiscard: (scope: SessionScope, path: string) =>
219
+ call<{ ok: true }>('git.discard', scopePayload(scope, { path })),
220
+ /** Revert one commit onto the current branch. */
221
+ gitRevert: (scope: SessionScope, hash: string) =>
222
+ call<{ ok: true }>('git.revert', scopePayload(scope, { hash })),
223
+ /** Cherry-pick one commit onto the current branch. */
224
+ gitCherryPick: (scope: SessionScope, hash: string) =>
225
+ call<{ ok: true }>('git.cherry-pick', scopePayload(scope, { hash })),
226
+ /** Release a terminal's process immediately (tab closed; the WS close frame
227
+ * may be unreachable while the socket is down, so the host also accepts
228
+ * this explicit route). */
229
+ ptyClose: (scope: SessionScope, tab: string) =>
230
+ call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
231
+ /** Release an agent terminal by uuid (tab closed while WS was down). */
232
+ agentPtyClose: (uuid: string) =>
233
+ call<{ ok: true }>('agent-pty.close', { uuid }),
234
+ /** Terminal dependency status (issue #140): after a WS close 1011 with
235
+ * reason `pty-deps-missing` the view fetches the full repair details here
236
+ * (the close reason itself is capped at 123 bytes). */
237
+ terminalDeps: () =>
238
+ call<TerminalDepsStatus>('terminal.deps', {}),
239
+ /**
240
+ * The output the model has read so far for one background job (replayed
241
+ * from the owner session's event log — never the model's job_output
242
+ * cursor). The scope MUST be the job's OWNER session.
243
+ */
244
+ jobOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
245
+ call<JobOutputResult>('jobs.output', scopePayload(scope, { id }), signal),
246
+ /** Request cancellation of one background job (live jobs flip to stopping). */
247
+ jobKill: (scope: SessionScope, id: string, reason?: string) =>
248
+ call<{ ok: true; outcome: 'requested' | 'already-finished' }>('jobs.kill', scopePayload(scope, {
249
+ id,
250
+ ...(reason !== undefined ? { reason } : {}),
251
+ })),
252
+ /**
253
+ * One batch live-preview fetch for the whole Subagent tree. The payload is
254
+ * the already-resolved topology ROOT (not a session scope); the host
255
+ * enumerates descendants once and folds running children's activity.
256
+ */
257
+ subagentsLive: (rootSessionId: string, signal?: AbortSignal) =>
258
+ call<SubagentLiveResult>('subagents.live', { rootSessionId }, signal),
259
+ /** Create a Side Chat thread: a child session seeded with the parent's
260
+ * full log up to now. Empty question = immediate create (Codex-style):
261
+ * the thread opens empty, the first prompt carries the boundary. */
262
+ sidechatStart: (sessionId: string, question?: string) =>
263
+ call<{ childId: string }>('sidechat.start', { sessionId, question: question ?? '' }),
264
+ /** Deliver one follow-up message to a Side Chat thread. */
265
+ sidechatPrompt: (childId: string, text: string) =>
266
+ call<{ accepted: true }>('sidechat.prompt', { childId, text }),
267
+ /** Abort a Side Chat thread's running turn (queued work is preserved). */
268
+ sidechatCancel: (childId: string) =>
269
+ call<{ accepted: true }>('sidechat.cancel', { childId }),
270
+ /** Release a Side Chat thread's live agent (history stays persisted). */
271
+ sidechatDispose: (childId: string) =>
272
+ call<{ accepted: true }>('sidechat.dispose', { childId }),
273
+ /** Live state + agent identity (provider/model/preset) of a thread. */
274
+ sidechatInfo: (childId: string) =>
275
+ call<SidechatThreadInfo>('sidechat.info', { childId }),
276
+ /** The effective terminal shell and its display name (plugin-global). */
277
+ shellGet: () =>
278
+ call<{ shell: string; name: string }>('shell.get', {}),
279
+ /** Whether the session may write files (host truth; default false = read-only). */
280
+ configGet: (scope: SessionScope) =>
281
+ call<{ editable: boolean }>('config.get', scopePayload(scope, {})),
282
+ /** Switch the session's write permission (the editor toolbar toggle). */
283
+ configSetEditable: (scope: SessionScope, editable: boolean) =>
284
+ call<{ ok: true }>('config.set-editable', scopePayload(scope, { editable })),
285
+ /** Read the side card preferences (plugin-global, no session scope). */
286
+ settingsGet: () =>
287
+ call<{ value?: unknown; revision?: number; externalDisable?: boolean }>('settings.get', {}),
288
+ /** Merge a patch into the side card preferences (revision-guarded). */
289
+ settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
290
+ call<{ value?: unknown; revision?: number }>('settings.update', {
291
+ patch,
292
+ ...(expectedRevision !== undefined ? { expectedRevision } : {}),
293
+ }),
294
+ /** Probe a URL's response headers (the sidebar browser's embeddability
295
+ * check; see the host's browser.probe route). */
296
+ browserProbe: (url: string, signal?: AbortSignal) =>
297
+ call<BrowserProbeResult>('browser.probe', { url }, signal),
298
+ /** External open for the file tree's "open with" menu: reveal a path in
299
+ * the OS file manager, or hand a custom-scheme URL (vscode://, cursor://,
300
+ * zed://, custom editors) to its registered handler. The host launches
301
+ * the platform opener (argv, no shell). */
302
+ openExternal: (payload: { action: 'reveal'; path: string } | { action: 'url'; url: string }) =>
303
+ call<{ started: boolean }>('open.external', payload),
304
+ }
305
+
306
+ /** Absolute URL of the media route for one path (images only). */
307
+ export function mediaUrl(scope: SessionScope, path: string): string {
308
+ return fileUrl(scope, path, false)
309
+ }
310
+
311
+ /** Absolute URL of the download route: serves raw bytes (binary-safe) with
312
+ * `Content-Disposition: attachment`, so the browser saves the file. */
313
+ export function downloadUrl(scope: SessionScope, path: string): string {
314
+ return fileUrl(scope, path, true)
315
+ }
316
+
317
+ /** Shared URL builder for the /sidebar/file route (media vs download). */
318
+ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
319
+ const params = new URLSearchParams({ sessionId: scope.sessionId, path })
320
+ if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
321
+ if (download) params.set('download', '1')
322
+ return `/sidebar/file?${params.toString()}`
323
+ }
324
+
325
+ /**
326
+ * Absolute URL of the HTML preview route (see html-route.ts): the path is
327
+ * fully encoded so the previewed page's relative assets resolve back into
328
+ * the same route with the session scope intact. The UNC marker is
329
+ * platform-neutral — the host's requireAbsolute resolves the decoded
330
+ * forward-slash `//server/share/...` form on both win32 and POSIX — so no
331
+ * client-side platform signal is needed.
332
+ */
333
+ export function htmlUrl(scope: SessionScope, path: string): string {
334
+ return encodeHtmlUrl(scope.sessionId, path)
335
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The "no preview — download instead" pane: shown by the editor host when no
3
+ * viewer can render a file (binary without a registered renderer, or the
4
+ * `binary-download` strategy) and registered as the `binary-download` viewer
5
+ * component so the declarative route and the host fallback share one UI.
6
+ */
7
+ import type { ReactNode } from 'react'
8
+ import type { SessionScope } from './api.ts'
9
+ import { downloadUrl } from './api.ts'
10
+ import { t } from './locales.ts'
11
+ import css from './sidebar.module.css'
12
+
13
+ export function BinaryDownload(props: { scope: SessionScope; path: string }): ReactNode {
14
+ const { scope, path } = props
15
+ return (
16
+ <div className={css.editorBinary}>
17
+ <span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
18
+ <a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
19
+ {t('downloadToView')}
20
+ </a>
21
+ </div>
22
+ )
23
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Narrow-viewport ("mobile") breakpoint for the sidebar. Width-based, shared
3
+ * by the layout logic (JS) and the style gates (CSS). The CSS side pairs
4
+ * with this file via `@media (max-width: 767px)` rules (sidebar.module.css)
5
+ * — 767px ≡ widths below NARROW_MAX_WIDTH, documented at both ends.
6
+ *
7
+ * "Real narrow" on purpose: the mobile layout (one full-screen drawer, the
8
+ * bottom panel's tabs merged into the right sidebar) is a phone / portrait
9
+ * tablet experience. The value is deliberately NOT aligned to the DSH app
10
+ * shell's own 1024px breakpoint — 1024px windows (small laptops, split
11
+ * panes) keep the desktop two-panel layout.
12
+ */
13
+ import { useEffect, useState } from 'react'
14
+
15
+ /** Viewport widths strictly below this are "mobile" (paired CSS: max-width: 767px). */
16
+ export const NARROW_MAX_WIDTH = 768
17
+
18
+ /** Whether a viewport width is narrow (mobile). */
19
+ export function isNarrowWidth(width: number): boolean {
20
+ return width < NARROW_MAX_WIDTH
21
+ }
22
+
23
+ /**
24
+ * Live narrow-viewport flag for components. Reads `window.innerWidth` and
25
+ * re-measures on resize (rAF-throttled, the repo's existing drag pattern).
26
+ * Deliberately avoids `matchMedia` (jsdom does not implement it) — the
27
+ * resize listener is equally exact for a breakpoint that never changes
28
+ * while the page is open.
29
+ */
30
+ export function useNarrowViewport(): boolean {
31
+ const [narrow, setNarrow] = useState(
32
+ () => typeof window !== 'undefined' && isNarrowWidth(window.innerWidth),
33
+ )
34
+ useEffect(() => {
35
+ if (typeof window === 'undefined') return
36
+ let frame: number | null = null
37
+ const measure = (): void => {
38
+ frame = null
39
+ setNarrow(isNarrowWidth(window.innerWidth))
40
+ }
41
+ const onResize = (): void => {
42
+ if (frame === null) frame = requestAnimationFrame(measure)
43
+ }
44
+ window.addEventListener('resize', onResize)
45
+ return () => {
46
+ window.removeEventListener('resize', onResize)
47
+ if (frame !== null) cancelAnimationFrame(frame)
48
+ }
49
+ }, [])
50
+ return narrow
51
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Pure URL policy for the built-in browser tab: normalize user input into
3
+ * an http(s) URL, and refuse destinations that would be dangerous to embed
4
+ * in the sidebar iframe. Kept dependency-free so it is unit-testable.
5
+ *
6
+ * The iframe sandbox (opaque origin, no allow-same-origin / top-navigation)
7
+ * is the primary security boundary; this module is the address-bar gate on
8
+ * top of it: only http/https may be navigated, and loopback addresses are
9
+ * refused so a browsed page cannot probe local services by user action.
10
+ * The GUI's OWN origin is explicitly ALLOWED — the user may open the GUI
11
+ * itself in the sidebar (debugging, mirroring); the sandbox still renders
12
+ * it in an opaque origin with no same-origin privileges, exactly like any
13
+ * other site.
14
+ */
15
+
16
+ /** Why a navigation attempt was refused. */
17
+ export type BrowserBlockReason = 'scheme' | 'loopback'
18
+
19
+ /** Result of normalizing one address-bar input. */
20
+ export type BrowserNavigateResult =
21
+ | { kind: 'ok'; url: string }
22
+ | { kind: 'blocked'; reason: BrowserBlockReason }
23
+ | { kind: 'invalid' }
24
+
25
+ /** One browser.probe wire result (host fetch of the target's headers). */
26
+ export interface BrowserProbeResult {
27
+ reachable: boolean
28
+ /** The final (post-redirect) URL; present when reachable. */
29
+ url?: string
30
+ status?: number
31
+ xFrameOptions?: string
32
+ /** The CSP frame-ancestors source list; present when the directive exists. */
33
+ frameAncestors?: string[]
34
+ }
35
+
36
+ /** Embeddability verdict of one probe. */
37
+ export type Embeddability = 'embeddable' | 'blocked' | 'unknown'
38
+
39
+ /**
40
+ * Decide whether a site can render inside the sidebar iframe. The signals
41
+ * are exactly the ones the BROWSER enforces when it refuses an iframe load:
42
+ * X-Frame-Options DENY/SAMEORIGIN, or a frame-ancestors directive that does
43
+ * not allow `*` ('self' here means the SITE's own origin — never ours, so
44
+ * it also blocks the sidebar). A site we could not reach yields 'unknown'
45
+ * and the plain iframe stays.
46
+ */
47
+ export function embeddabilityOf(probe: BrowserProbeResult): Embeddability {
48
+ if (probe.reachable !== true) return 'unknown'
49
+ const xfo = probe.xFrameOptions?.trim().toUpperCase()
50
+ if (xfo === 'DENY' || xfo === 'SAMEORIGIN') return 'blocked'
51
+ if (probe.frameAncestors !== undefined && !probe.frameAncestors.some(source => source === '*')) return 'blocked'
52
+ return 'embeddable'
53
+ }
54
+
55
+ /** A loopback hostname (localhost, IPv6 ::1, 127.0.0.0/8, 0.0.0.0). */
56
+ export function isLoopbackHostname(hostname: string): boolean {
57
+ const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
58
+ if (host === 'localhost' || host === '::1' || host === '0.0.0.0') return true
59
+ const parts = host.split('.')
60
+ return parts.length === 4
61
+ && parts[0] === '127'
62
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
63
+ }
64
+
65
+ /**
66
+ * Normalize one address-bar input against the navigation policy.
67
+ * @param input - raw user text.
68
+ * @param selfOrigin - the GUI's own origin (window.location.origin). The GUI
69
+ * itself may be browsed in the sidebar (the sandbox keeps it opaque), so it
70
+ * is let through BEFORE the loopback check — its host is normally loopback.
71
+ */
72
+ /** Schemes that must never reach the iframe, even without `//` (javascript:,
73
+ * data:, file:, ...). Host:port lookalikes (example.com:8080) are NOT here —
74
+ * they parse as hosts below. */
75
+ const FORBIDDEN_SCHEMES = new Set([
76
+ 'javascript', 'data', 'file', 'about', 'vbscript', 'blob',
77
+ 'mailto', 'tel', 'ftp', 'ftps', 'ws', 'wss', 'sftp', 'ssh',
78
+ 'chrome', 'chrome-extension', 'moz-extension', 'edge', 'opera', 'resource', 'view-source',
79
+ ])
80
+
81
+ export function normalizeBrowserUrl(input: string, selfOrigin: string): BrowserNavigateResult {
82
+ const trimmed = input.trim()
83
+ if (trimmed === '') return { kind: 'invalid' }
84
+ // Distinguish an explicit scheme from a bare host:port. "example.com:8080"
85
+ // would match a naive scheme regex (dots are legal in schemes), so a
86
+ // scheme prefix is only honored when it is http(s) or a known-forbidden
87
+ // scheme; anything else is treated as a host and gets https://.
88
+ const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
89
+ let withScheme: string
90
+ if (schemeMatch === null) {
91
+ withScheme = `https://${trimmed}`
92
+ } else {
93
+ const scheme = schemeMatch[1]!.toLowerCase()
94
+ if (scheme === 'http' || scheme === 'https') withScheme = trimmed
95
+ else if (FORBIDDEN_SCHEMES.has(scheme)) return { kind: 'blocked', reason: 'scheme' }
96
+ else withScheme = `https://${trimmed}`
97
+ }
98
+ let url: URL
99
+ try {
100
+ url = new URL(withScheme)
101
+ } catch {
102
+ return { kind: 'invalid' }
103
+ }
104
+ // The protocol backstop: any URL that still parses to a non-http(s)
105
+ // scheme (e.g. ftp://, ws:// — which carry `//` and skip the list) is
106
+ // refused here.
107
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return { kind: 'blocked', reason: 'scheme' }
108
+ // The GUI's own origin is ALLOWED (the user may browse the GUI itself in
109
+ // the sidebar; the sandbox renders it in an opaque origin like any other
110
+ // site). It must be checked before the loopback gate because its host is
111
+ // normally loopback.
112
+ try {
113
+ if (url.origin === new URL(selfOrigin).origin) return { kind: 'ok', url: url.href }
114
+ } catch {
115
+ // Unparsable selfOrigin (never in practice): fall through to the loopback gate.
116
+ }
117
+ if (isLoopbackHostname(url.hostname)) return { kind: 'blocked', reason: 'loopback' }
118
+ return { kind: 'ok', url: url.href }
119
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Built-in registration: the plugin registers its own tab pages and file
3
+ * previewers through the same {@link BetterSidebarService} external plugins
4
+ * use — eating its own dogfood. The descriptors live next to their feature
5
+ * modules (tabs.tsx / viewers.tsx); this module only aggregates them and
6
+ * owns the disposer lifecycle (cordis auto-invokes it on fiber disposal,
7
+ * HMR-safe).
8
+ */
9
+ import type { Context } from '../../context-types.ts'
10
+ import type { BetterSidebarService } from '../service.ts'
11
+ import { builtinTabs, type BuiltinTabOptions } from './tabs.tsx'
12
+ import { builtinViewers } from './viewers.tsx'
13
+
14
+ /**
15
+ * Register all built-in tabs and viewers with the service. Returns a
16
+ * disposer that unregisters everything (cordis auto-invokes it on fiber
17
+ * disposal). The `ctx` is threaded into tab descriptors that need it
18
+ * (EditorHost reads `ctx.betterSidebar` for file-viewer matching).
19
+ */
20
+ export function registerBuiltins(
21
+ ctx: Context,
22
+ service: BetterSidebarService,
23
+ options: BuiltinTabOptions = {},
24
+ ): () => void {
25
+ const disposers: (() => void)[] = []
26
+ for (const tab of builtinTabs(ctx, options)) {
27
+ disposers.push(service.registerTab(tab))
28
+ }
29
+ for (const viewer of builtinViewers()) {
30
+ disposers.push(service.registerFileViewer(viewer))
31
+ }
32
+ return () => {
33
+ for (const d of disposers) {
34
+ try { d() } catch { /* already disposed */ }
35
+ }
36
+ }
37
+ }