@chatcode/cco-better-sidebar 0.18.0-rc.3 → 0.18.0-rc.4

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 (36) hide show
  1. package/README.md +5 -1
  2. package/README_EN.md +5 -1
  3. package/lib/client-editor.js +315 -258
  4. package/lib/client-mermaid.js +298 -256
  5. package/lib/client-registry.js +1097 -460
  6. package/lib/client-terminal.js +318 -261
  7. package/lib/client.js +1110 -473
  8. package/lib/index.js +47 -1
  9. package/lib/types/client/PluginMarketSettingsSection.d.ts +7 -0
  10. package/lib/types/client/PluginMarketView.d.ts +7 -0
  11. package/lib/types/client/api.d.ts +15 -0
  12. package/lib/types/client/builtins/tabs.d.ts +6 -3
  13. package/lib/types/client/locales.d.ts +21 -0
  14. package/lib/types/client/plugin-market-catalog.d.ts +25 -0
  15. package/lib/types/client/ui-tweaks/index.d.ts +1 -1
  16. package/lib/types/client/ui-tweaks/typography.d.ts +9 -0
  17. package/lib/types/plugin-market-api.d.ts +5 -0
  18. package/lib/types/plugin-market-shared.d.ts +37 -0
  19. package/lib/types/ui-tweaks/config.d.ts +5 -6
  20. package/package.json +1 -1
  21. package/src/client/PluginMarketSettingsSection.tsx +55 -0
  22. package/src/client/PluginMarketView.module.css +35 -0
  23. package/src/client/PluginMarketView.tsx +154 -0
  24. package/src/client/api.ts +7 -0
  25. package/src/client/builtins/tabs.tsx +16 -3
  26. package/src/client/index.tsx +10 -1
  27. package/src/client/locales-ja.ts +21 -0
  28. package/src/client/locales.ts +42 -0
  29. package/src/client/plugin-market-catalog.ts +90 -0
  30. package/src/client/ui-tweaks/branding.tsx +15 -7
  31. package/src/client/ui-tweaks/index.tsx +36 -99
  32. package/src/client/ui-tweaks/typography.ts +38 -0
  33. package/src/index.ts +2 -0
  34. package/src/plugin-market-api.ts +42 -0
  35. package/src/plugin-market-shared.ts +21 -0
  36. package/src/ui-tweaks/config.ts +10 -10
@@ -39,6 +39,8 @@ import { SideCardSection } from './SideCardSection.tsx'
39
39
  import { UsageHeatmapSection } from './UsageHeatmapSection.tsx'
40
40
  import { AiRatioSection } from './AiRatioSection.tsx'
41
41
  import { UpdateSettingsSection } from './UpdateSettingsSection.tsx'
42
+ import { registerPluginMarketSettings } from './PluginMarketSettingsSection.tsx'
43
+ import { createPluginMarketCatalog } from './plugin-market-catalog.ts'
42
44
  import { api } from './api.ts'
43
45
  import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en,
44
46
  ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
@@ -142,6 +144,11 @@ export function apply(ctx: Context): void {
142
144
  // registrations (the official createXXXStore() factory rule — no
143
145
  // module-level singleton).
144
146
  const sidebarStore = createSidebarStore()
147
+ const pluginMarketCatalog = createPluginMarketCatalog()
148
+ ctx.effect(() => {
149
+ pluginMarketCatalog.start()
150
+ return () => pluginMarketCatalog.dispose()
151
+ }, 'dsh-better-sidebar: plugin catalogs')
145
152
  // Extend (rather than replace) DSH's single-owner Workspace browser with a
146
153
  // durable Pinned sessions peer group and an extra native row-menu action.
147
154
  // The native browser keeps ownership of search, workspace management and
@@ -180,7 +187,7 @@ export function apply(ctx: Context): void {
180
187
  // service (eating our own dogfood). The disposer unregisters them on
181
188
  // fiber disposal (HMR-safe).
182
189
  ctx.effect(
183
- () => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle }),
190
+ () => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle, pluginMarketCatalog }),
184
191
  'dsh-better-sidebar: register built-in tabs and viewers',
185
192
  )
186
193
  // A failure anywhere in the client lifecycle must never take the app down
@@ -481,6 +488,8 @@ export function apply(ctx: Context): void {
481
488
  'dsh-better-sidebar: skill market navigation icon fallback',
482
489
  )
483
490
 
491
+ registerPluginMarketSettings(ctx, pluginMarketCatalog)
492
+
484
493
  // The "Side card" settings section: appears in the DSH Settings shell
485
494
  // once the shell's declaration is on the ledger (slots.inject waits for
486
495
  // it); the section reads/writes the prefs through the plugin's own
@@ -16,6 +16,27 @@
16
16
 
17
17
  /** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
18
18
  export const ja: Record<string, string> = {
19
+ pluginMarketTitle: 'プラグインマーケット',
20
+ pluginMarketProjectDirectory: 'プロジェクトディレクトリ:{path}',
21
+ pluginMarketInstalledTab: 'インストール済み',
22
+ pluginMarketRefresh: '更新',
23
+ pluginMarketLoading: 'プラグインを読み込み中…',
24
+ pluginMarketRefreshing: '一覧を更新中…',
25
+ pluginMarketEmpty: 'プラグインはありません。',
26
+ pluginMarketBack: '戻る',
27
+ pluginMarketInstall: 'インストール',
28
+ pluginMarketUninstall: 'アンインストール',
29
+ pluginMarketProject: 'プロジェクト',
30
+ pluginMarketUser: 'ユーザー',
31
+ pluginMarketInstalled: 'プラグインをインストールしました。',
32
+ pluginMarketUninstalled: 'プラグインをアンインストールしました。',
33
+ pluginMarketSubtitle: 'マーケットを参照し、プロジェクトまたはユーザー環境にインストールします。',
34
+ pluginMarketSearch: 'マーケットやプラグインを検索',
35
+ pluginMarketDefault: 'デフォルト',
36
+ pluginMarketBrowse: 'プラグインを見る',
37
+ pluginMarketNoDescription: '説明なし',
38
+ pluginMarketInstalling: 'インストール中…',
39
+ pluginMarketUninstalling: 'アンインストール中…',
19
40
  accountMenuSettings: '設定',
20
41
  accountMenuLogout: 'ログアウト',
21
42
  accountMenuLabel: 'アカウントメニュー',
@@ -23,6 +23,27 @@
23
23
 
24
24
  /** The zh dictionary (also registered into the ChatCode CLI locale registry under {@link LOCALE_NS}). */
25
25
  export const zh = {
26
+ pluginMarketTitle: '插件市场',
27
+ pluginMarketProjectDirectory: '项目目录:{path}',
28
+ pluginMarketInstalledTab: '已安装',
29
+ pluginMarketRefresh: '刷新',
30
+ pluginMarketLoading: '正在加载插件…',
31
+ pluginMarketRefreshing: '正在刷新列表…',
32
+ pluginMarketEmpty: '暂无插件。',
33
+ pluginMarketBack: '返回',
34
+ pluginMarketInstall: '安装',
35
+ pluginMarketUninstall: '卸载',
36
+ pluginMarketProject: '项目级',
37
+ pluginMarketUser: '用户级',
38
+ pluginMarketInstalled: '插件安装成功。',
39
+ pluginMarketUninstalled: '插件卸载成功。',
40
+ pluginMarketSubtitle: '浏览插件市场,选择安装到当前项目或用户环境。',
41
+ pluginMarketSearch: '搜索市场或插件',
42
+ pluginMarketDefault: '默认',
43
+ pluginMarketBrowse: '查看插件',
44
+ pluginMarketNoDescription: '暂无描述',
45
+ pluginMarketInstalling: '安装中…',
46
+ pluginMarketUninstalling: '卸载中…',
26
47
  accountMenuSettings: '设置',
27
48
  accountMenuLogout: '退出登录',
28
49
  accountMenuLabel: '账户菜单',
@@ -494,6 +515,27 @@ export const zh = {
494
515
 
495
516
  /** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
496
517
  export const en: Record<keyof typeof zh, string> = {
518
+ pluginMarketTitle: 'Plugin marketplace',
519
+ pluginMarketProjectDirectory: 'Project directory: {path}',
520
+ pluginMarketInstalledTab: 'Installed',
521
+ pluginMarketRefresh: 'Refresh',
522
+ pluginMarketLoading: 'Loading plugins…',
523
+ pluginMarketRefreshing: 'Refreshing list…',
524
+ pluginMarketEmpty: 'No plugins.',
525
+ pluginMarketBack: 'Back',
526
+ pluginMarketInstall: 'Install',
527
+ pluginMarketUninstall: 'Uninstall',
528
+ pluginMarketProject: 'Project',
529
+ pluginMarketUser: 'User',
530
+ pluginMarketInstalled: 'Plugin installed.',
531
+ pluginMarketUninstalled: 'Plugin uninstalled.',
532
+ pluginMarketSubtitle: 'Browse marketplaces and install plugins for your project or user.',
533
+ pluginMarketSearch: 'Search marketplaces or plugins',
534
+ pluginMarketDefault: 'Default',
535
+ pluginMarketBrowse: 'Browse plugins',
536
+ pluginMarketNoDescription: 'No description',
537
+ pluginMarketInstalling: 'Installing…',
538
+ pluginMarketUninstalling: 'Uninstalling…',
497
539
  accountMenuSettings: 'Settings',
498
540
  accountMenuLogout: 'Log out',
499
541
  accountMenuLabel: 'Account menu',
@@ -0,0 +1,90 @@
1
+ /** App-lifetime remote catalogs, shared by Settings and the sidebar tab. */
2
+ import { api } from './api.ts'
3
+ import type { MarketplaceCatalog, MarketplacePlugin } from '../plugin-market-shared.ts'
4
+
5
+ interface Entry<T> {
6
+ readonly status: 'loading' | 'ready' | 'error'
7
+ readonly data?: T
8
+ readonly error?: string
9
+ readonly warning?: string
10
+ }
11
+ interface Snapshot {
12
+ readonly markets?: Entry<MarketplaceCatalog>
13
+ readonly plugins: Readonly<Record<string, Entry<MarketplacePlugin[]>>>
14
+ readonly refresh?: { readonly status: 'loading' | 'ready' | 'error'; readonly error?: string }
15
+ }
16
+ export interface PluginMarketCatalog {
17
+ subscribe(listener: () => void): () => void
18
+ getSnapshot(): Snapshot
19
+ start(): void
20
+ ensure(marketplaceId: string): void
21
+ refresh(marketplaceId?: string): void
22
+ dispose(): void
23
+ }
24
+
25
+ export function createPluginMarketCatalog(): PluginMarketCatalog {
26
+ let snapshot: Snapshot = { plugins: {} }
27
+ let disposed = false
28
+ const listeners = new Set<() => void>()
29
+ const controllers = new Set<AbortController>()
30
+ const requests = new Map<string, Promise<string | undefined>>()
31
+ const publish = (next: Snapshot): void => {
32
+ if (disposed) return
33
+ snapshot = next
34
+ listeners.forEach(listener => listener())
35
+ }
36
+ const load = <T>(
37
+ key: string,
38
+ current: () => Entry<T> | undefined,
39
+ update: (entry: Entry<T>) => void,
40
+ request: (signal: AbortSignal) => Promise<{ data: T; warning?: string }>,
41
+ ): Promise<string | undefined> => {
42
+ if (disposed) return Promise.resolve(undefined)
43
+ const pending = requests.get(key)
44
+ if (pending) return pending
45
+ const entry: Entry<T> = { status: 'loading', data: current()?.data }
46
+ const controller = new AbortController()
47
+ controllers.add(controller)
48
+ update(entry)
49
+ const result = request(controller.signal).then(
50
+ ({ data, warning }) => { if (current() === entry) update({ status: 'ready', data, ...(warning ? { warning } : {}) }); return undefined },
51
+ cause => {
52
+ const error = cause instanceof Error ? cause.message : String(cause)
53
+ if (current() === entry) update({ status: 'error', data: entry.data, error })
54
+ return error
55
+ },
56
+ ).finally(() => { controllers.delete(controller); requests.delete(key) })
57
+ requests.set(key, result)
58
+ return result
59
+ }
60
+ const markets = (force = false): Promise<string | undefined> => {
61
+ if (!force && snapshot.markets) return Promise.resolve(undefined)
62
+ return load('markets', () => snapshot.markets, entry => publish({ ...snapshot, markets: entry }), async signal => ({ data: await api.pluginMarketplaces(signal) }))
63
+ }
64
+ const plugins = (id: string, force = false): Promise<string | undefined> => {
65
+ if (!force && snapshot.plugins[id]) return Promise.resolve(undefined)
66
+ return load(`plugins:${id}`, () => snapshot.plugins[id], entry => publish({ ...snapshot, plugins: { ...snapshot.plugins, [id]: entry } }), async signal => {
67
+ const result = await api.pluginCatalog(id, signal)
68
+ return Array.isArray(result) ? { data: result } : { data: result.plugins, warning: result.warning }
69
+ })
70
+ }
71
+ return {
72
+ subscribe: listener => { listeners.add(listener); return () => { listeners.delete(listener) } },
73
+ getSnapshot: () => snapshot,
74
+ start: () => { void markets() },
75
+ ensure: id => { void plugins(id) },
76
+ refresh: id => {
77
+ if (disposed || snapshot.refresh?.status === 'loading') return
78
+ // Refresh every previously visited catalog before unlocking navigation.
79
+ // Keep cached cards for layout continuity; views disable their controls.
80
+ const ids = new Set(Object.keys(snapshot.plugins))
81
+ if (id) ids.add(id)
82
+ publish({ ...snapshot, refresh: { status: 'loading' } })
83
+ void Promise.all([markets(true), ...[...ids].map(key => plugins(key, true))]).then(results => {
84
+ const errors = [...new Set(results.filter((value): value is string => value !== undefined))]
85
+ publish({ ...snapshot, refresh: errors.length ? { status: 'error', error: errors.join('\n') } : { status: 'ready' } })
86
+ })
87
+ },
88
+ dispose: () => { disposed = true; controllers.forEach(controller => controller.abort()); controllers.clear(); requests.clear(); listeners.clear() },
89
+ }
90
+ }
@@ -30,6 +30,8 @@ const PROJECT_NAME = 'ChatCode CLI'
30
30
  const HERO_TITLE = '与世界,深一度对话'
31
31
  const LEGACY_HOST_BRAND_TEXT = /DSH\s*(?:Local\s*Build|本地构建)|DeepSeek\s*Harness|ChatCode\s*CLI/giu
32
32
  const LEGACY_HOST_BRAND_LABEL_TEXT = /^(?:DSH\s*(?:Local\s*Build|本地构建)|ChatCode\s*CLI)$/iu
33
+ const BRAND_SURFACE = '[class*="brandIdentity"],[class*="brandName"],[class*="headline"],.dut-sidebar-brand-name'
34
+ const USER_CONTENT = 'input,textarea,[contenteditable],[role="textbox"],pre,code,script,style'
33
35
  const HOST_BUILD_VERSION_TEXT = /^v?\d+\.\d+\.\d+(?:[-+][0-9a-z.-]+)*$/iu
34
36
  const HERO_HEADLINE = /探索未至之境/u
35
37
  const HERO_PREVIEW_TEXT = /^(?:预览版|Preview)$/iu
@@ -104,6 +106,9 @@ body[data-ds-dark-theme] .dut-unicom-primary-action:not(:hover){background:#9B0B
104
106
  `
105
107
 
106
108
  function hasPrimaryLabel(button: HTMLButtonElement): boolean {
109
+ // Plugin cards own their appearance. Inventory disclosure buttons include
110
+ // descriptions, where words like "install" do not identify a primary action.
111
+ if (button.closest('[data-chatcode-plugin-market], [data-plugin-module]')) return false
107
112
  const label = `${button.textContent ?? ''} ${button.getAttribute('aria-label') ?? ''} ${button.title}`.trim().toLowerCase()
108
113
  return /(?:^|\s)(new session|send|install)(?:\s|$)|新会话|发送|(?:^|\s)安装(?:\s|$)/u.test(label)
109
114
  }
@@ -193,19 +198,22 @@ function syncBranding(): void {
193
198
 
194
199
  // The sidebar label identifies the precise icon to its left. Process it
195
200
  // before the generic text pass so the sibling relationship is still intact.
196
- document.querySelectorAll<HTMLElement>('body *').forEach((element) => {
197
- if (element.childElementCount !== 0 || !LEGACY_HOST_BRAND_LABEL_TEXT.test(element.textContent?.trim() ?? '')) return
198
- if (element.textContent !== PROJECT_NAME) element.textContent = PROJECT_NAME
199
- element.classList.add('dut-brand-title')
200
- replaceLogoBesideBrandLabel(element)
201
- hideVersionBesideBrandLabel(element)
201
+ document.querySelectorAll<HTMLElement>(BRAND_SURFACE).forEach((surface) => {
202
+ surface.querySelectorAll<HTMLElement>('*').forEach((element) => {
203
+ if (element.closest(USER_CONTENT) !== null) return
204
+ if (element.childElementCount !== 0 || !LEGACY_HOST_BRAND_LABEL_TEXT.test(element.textContent?.trim() ?? '')) return
205
+ if (element.textContent !== PROJECT_NAME) element.textContent = PROJECT_NAME
206
+ element.classList.add('dut-brand-title')
207
+ replaceLogoBesideBrandLabel(element)
208
+ hideVersionBesideBrandLabel(element)
209
+ })
202
210
  })
203
211
 
204
212
  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT)
205
213
  let node: Text | null
206
214
  while ((node = walker.nextNode() as Text | null) !== null) {
207
215
  const parent = node.parentElement
208
- if (parent?.closest('script,style') !== null) continue
216
+ if (parent === null || parent.closest(USER_CONTENT) !== null || parent.closest(BRAND_SURFACE) === null) continue
209
217
  if (LEGACY_HOST_BRAND_TEXT.test(node.data)) {
210
218
  LEGACY_HOST_BRAND_TEXT.lastIndex = 0
211
219
  node.data = node.data.replace(LEGACY_HOST_BRAND_TEXT, PROJECT_NAME)
@@ -36,6 +36,7 @@ import { WhaleIndicator, installWhaleStyles } from './whale.tsx'
36
36
  import { PreciseCacheHitEntry } from './cachehit.tsx'
37
37
  import { installTaskNotifier, previewAlerts, requestNotifyPermission } from './notifier.ts'
38
38
  import { HeroProjectLogo, installBranding, ProjectLogoIcon, SidebarProjectLogo, SidebarProjectName } from './branding.tsx'
39
+ import { buildFontCss } from './typography.ts'
39
40
 
40
41
  const NS = 'ui-tweaks'
41
42
  const SETTINGS_ROUTE = '/_dsh/ui-tweaks/settings'
@@ -43,12 +44,12 @@ const SETTINGS_ROUTE = '/_dsh/ui-tweaks/settings'
43
44
  /** Client context narrowed to the browser sessions face in this combined build. */
44
45
  type UITweaksClientContext = ClientContext & { sessions: ISessions }
45
46
 
46
- const DEFAULT_FONT_SIZE = 16
47
+ const DEFAULT_FONT_SIZE = 14
47
48
  const MIN_FONT_SIZE = 10
48
49
  const MAX_FONT_SIZE = 32
49
50
  /** Legacy: code font as a percentage of the message font size (81% = stock 13/16). */
50
51
  const DEFAULT_CODE_FONT_SCALE = 81
51
- /** Absolute code font size (px): 13 matches the host default at a 16px body. */
52
+ /** Default absolute code-block size, paired with 14px prose. */
52
53
  const DEFAULT_CODE_FONT_SIZE = 13
53
54
  const MIN_CODE_FONT_SIZE = 8
54
55
  const MAX_CODE_FONT_SIZE = 32
@@ -134,7 +135,7 @@ const en = {
134
135
  fontSize: 'Message font size',
135
136
  fontSizeHint: `Number between ${MIN_FONT_SIZE} and ${MAX_FONT_SIZE}; applies to message text, headings, tables and code.`,
136
137
  codeFontSize: 'Code font size',
137
- codeFontSizeHint: `Absolute code size in px (${MIN_CODE_FONT_SIZE}–${MAX_CODE_FONT_SIZE}); ${DEFAULT_CODE_FONT_SIZE}px is ChatCode CLI's default at a 16px body. Applies to code blocks; inline code follows proportionally.`,
138
+ codeFontSizeHint: `Absolute code size in px (${MIN_CODE_FONT_SIZE}–${MAX_CODE_FONT_SIZE}); ${DEFAULT_CODE_FONT_SIZE}px is ChatCode CLI's default at a 14px body. Applies to code blocks; inline code follows proportionally.`,
138
139
  lineHeight: 'Line spacing',
139
140
  lineHeightHint: `Base vertical spacing in px: gaps between message rows (Think ↔ tool cards) and blocks inside one reply, plus text line-height, paragraph and list margins, all scale from it; ${DEFAULT_LINE_HEIGHT} is ChatCode CLI's default.`,
140
141
  tableStyle: 'Table style',
@@ -334,7 +335,7 @@ const zh: Record<LocaleKey, string> = {
334
335
  fontSize: '消息字体大小',
335
336
  fontSizeHint: `取值 ${MIN_FONT_SIZE}–${MAX_FONT_SIZE},作用于消息正文、标题、表格与代码。`,
336
337
  codeFontSize: '代码字号',
337
- codeFontSizeHint: `代码绝对字号,取值 ${MIN_CODE_FONT_SIZE}–${MAX_CODE_FONT_SIZE}px;${DEFAULT_CODE_FONT_SIZE}px 为 ChatCode CLI 默认(正文 16 时)。作用于代码块,行内代码按比例跟随。`,
338
+ codeFontSizeHint: `代码绝对字号,取值 ${MIN_CODE_FONT_SIZE}–${MAX_CODE_FONT_SIZE}px;${DEFAULT_CODE_FONT_SIZE}px 为 ChatCode CLI 默认(正文 14 时)。作用于代码块,行内代码按比例跟随。`,
338
339
  lineHeight: '行高',
339
340
  lineHeightHint: `回复区垂直间距的基准值:消息之间(如 Think ↔ 工具卡片)、回复内块之间、正文行高、段落与列表边距都按它缩放;取值 ${MIN_LINE_HEIGHT}–${MAX_LINE_HEIGHT}px,${DEFAULT_LINE_HEIGHT} 为 ChatCode CLI 默认。`,
340
341
  tableStyle: '表格样式',
@@ -533,11 +534,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
533
534
 
534
535
  function resolveValue(value: TweaksValue | undefined): ResolvedTweaks {
535
536
  const fontSize = typeof value?.fontSize === 'number' ? value.fontSize : DEFAULT_FONT_SIZE
536
- // Effective code size: the absolute px input wins; otherwise derive px from
537
- // the legacy percentage at the resolved body size; otherwise stock.
537
+ const codeFontScale = value?.codeFontScale ?? DEFAULT_CODE_FONT_SCALE
538
+ // Explicit code sizing wins; the stock preference keeps readable 13px code.
538
539
  const codeFontSize = typeof value?.codeFontSize === 'number'
539
540
  ? Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, value.codeFontSize))
540
- : Math.max(8, Math.round(fontSize * (13 / 16) * ((value?.codeFontScale ?? DEFAULT_CODE_FONT_SCALE) / DEFAULT_CODE_FONT_SCALE)))
541
+ : codeFontScale === DEFAULT_CODE_FONT_SCALE
542
+ ? DEFAULT_CODE_FONT_SIZE
543
+ : Math.max(8, Math.round(fontSize * (13 / 16) * (codeFontScale / DEFAULT_CODE_FONT_SCALE)))
541
544
  return {
542
545
  fontSize,
543
546
  codeFontSize,
@@ -559,50 +562,6 @@ function resolveValue(value: TweaksValue | undefined): ResolvedTweaks {
559
562
  }
560
563
  }
561
564
 
562
- /** Scale one theme px value proportionally to the chosen base size. */
563
- function rel(base: number, fontSize: number): number {
564
- return Math.max(8, Math.round((base / DEFAULT_FONT_SIZE) * fontSize))
565
- }
566
-
567
- /** Rebuild the markdown font tokens for the chosen base size, keeping the theme faces. */
568
- function buildFontCss(fontSize: number, lineHeight: number, codeFontSize: number): string {
569
- const cs = getComputedStyle(document.body)
570
- const fam = (name: string, fallback: string): string => {
571
- const value = cs.getPropertyValue(name).trim()
572
- return value.length > 0 ? value : fallback
573
- }
574
- const base = fam('--dsw-font-markdown-base-font-family', '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif')
575
- const code = fam('--dsw-font-markdown-code-font-family', '"SF Mono", Consolas, monospace')
576
- const codeBlock = fam('--dsw-font-markdown-code-block-font-family', '"SF Mono", Consolas, monospace')
577
-
578
- const parts: string[] = []
579
- // `baseLine` is the token's line-height at the 16px baseline; it scales with
580
- // both the chosen font size and the 行高 unit, so at the default (16) it
581
- // reproduces the stock rhythm exactly.
582
- const token = (shorthand: string, size: number, baseLine: number, family: string): void => {
583
- const line = Math.max(12, Math.round((baseLine / DEFAULT_FONT_SIZE) * fontSize * (lineHeight / DEFAULT_LINE_HEIGHT)))
584
- parts.push(`--${shorthand}:${size}px/${line}px ${family}`)
585
- parts.push(`--${shorthand}-font-size:${size}px`)
586
- parts.push(`--${shorthand}-line-height:${line}px`)
587
- }
588
- // Code fonts hang off the absolute code-block size (stock: 13px at a 16px
589
- // body), with inline code slightly larger and the small variant slightly
590
- // smaller, preserving DSH's hierarchy (14/13 and 12/13 of the block).
591
- const codePx = (blockRatio: number): number => Math.max(8, Math.round(codeFontSize * blockRatio))
592
- token('dsw-font-markdown-base', fontSize, 28, base)
593
- token('dsw-font-markdown-base-strong', fontSize, 28, base)
594
- token('dsw-font-markdown-base-italic', fontSize, 28, base)
595
- token('dsw-font-markdown-base-strong-italic', fontSize, 28, base)
596
- token('dsw-font-markdown-h1', rel(24, fontSize), 34, base)
597
- token('dsw-font-markdown-h2', rel(22, fontSize), 32, base)
598
- token('dsw-font-markdown-h3', rel(20, fontSize), 30, base)
599
- token('dsw-font-markdown-h4', fontSize, 28, base)
600
- token('dsw-font-markdown-code', codePx(14 / 13), 22, code)
601
- token('dsw-font-markdown-code-block', codePx(1), 22, codeBlock)
602
- token('dsw-font-markdown-code-block-small', codePx(12 / 13), 18, codeBlock)
603
- return `body{${parts.join(';')}}`
604
- }
605
-
606
565
  /**
607
566
  * Claude Desktop-ish markdown table look: light-gray rounded cell cards with
608
567
  * small gaps, no borders. Cells share the theme's inline-code background and
@@ -610,7 +569,7 @@ function buildFontCss(fontSize: number, lineHeight: number, codeFontSize: number
610
569
  * renderer, so headers and cells always match.
611
570
  */
612
571
  const CLAUDE_TABLE_CSS = `
613
- div[data-slot="conversation.chat.node"] table{
572
+ div[data-slot="conversation.chat.node"] [class*="_markdown"]:not([data-markdown-variant="compact"]) table{
614
573
  border-collapse:separate !important;
615
574
  border-spacing:3px !important;
616
575
  width:100% !important;
@@ -621,7 +580,7 @@ div[data-slot="conversation.chat.node"] table{
621
580
  border:none !important;
622
581
  font-size:var(--dsw-font-markdown-base-font-size) !important;
623
582
  }
624
- div[data-slot="conversation.chat.node"] table thead th{
583
+ div[data-slot="conversation.chat.node"] [class*="_markdown"]:not([data-markdown-variant="compact"]) table thead th{
625
584
  background:var(--dsw-alias-markdown-inline-code) !important;
626
585
  color:inherit !important;
627
586
  font-weight:400 !important;
@@ -630,7 +589,7 @@ div[data-slot="conversation.chat.node"] table thead th{
630
589
  border:none !important;
631
590
  border-radius:6px !important;
632
591
  }
633
- div[data-slot="conversation.chat.node"] table tbody td{
592
+ div[data-slot="conversation.chat.node"] [class*="_markdown"]:not([data-markdown-variant="compact"]) table tbody td{
634
593
  background:var(--dsw-alias-markdown-inline-code) !important;
635
594
  color:inherit !important;
636
595
  font-size:inherit !important;
@@ -639,8 +598,8 @@ div[data-slot="conversation.chat.node"] table tbody td{
639
598
  border:none !important;
640
599
  border-radius:6px !important;
641
600
  }
642
- div[data-slot="conversation.chat.node"] table code,
643
- div[data-slot="conversation.chat.node"] table pre{
601
+ div[data-slot="conversation.chat.node"] [class*="_markdown"]:not([data-markdown-variant="compact"]) table code,
602
+ div[data-slot="conversation.chat.node"] [class*="_markdown"]:not([data-markdown-variant="compact"]) table pre{
644
603
  background:transparent !important;
645
604
  border:none !important;
646
605
  box-shadow:none !important;
@@ -650,52 +609,32 @@ div[data-slot="conversation.chat.node"] table pre{
650
609
  function buildRuntimeCss(value: ResolvedTweaks): string {
651
610
  const rules: string[] = []
652
611
  rules.push(buildFontCss(value.fontSize, value.lineHeight, value.codeFontSize))
653
- // User-sent messages use their own fixed font-size (not the markdown tokens);
654
- // route them through the same base so they follow the fontSize setting too.
612
+ // Sent messages share the reply metrics, including explicit UI Tweaks sizes.
655
613
  // `steering` messages are sent while the agent is busy (busyEnter: steer);
656
614
  // `[data-pending-steering]` is their in-flight bubble before it becomes durable.
657
- rules.push(`[data-chat-flow-kind="user"] [class^="_text_"],[data-chat-flow-kind="steering"] [class^="_text_"],[data-pending-steering] [class^="_text_"]{font-size:var(--dsw-font-markdown-base-font-size) !important}`)
658
- // Composer input follows the same base size. The visible text is rendered by
659
- // the hidden-textarea + backdrop/mirror pattern: the textarea is transparent,
660
- // the `data-input-backdrop` paints the text you see, and `data-input-mirror`
661
- // drives auto-grow. All three (plus the placeholder) must share the size.
662
- // Only font-size is touched: line-height is part of the auto-grow/caret
663
- // metrics, so overriding it misplaces the caret.
664
- rules.push(`[data-composer-card="true"] textarea,[data-composer-card="true"] [data-input-backdrop],[data-composer-card="true"] [data-input-mirror]{font-size:var(--dsw-font-markdown-base-font-size) !important}`)
665
- rules.push(`[data-composer-card="true"] textarea::placeholder{font-size:var(--dsw-font-markdown-base-font-size) !important}`)
666
- // Markdown table cells are pinned by DSH (15px); let every table style follow
667
- // the fontSize setting as well.
668
- rules.push(`div[data-slot="conversation.chat.node"] table th,div[data-slot="conversation.chat.node"] table td{font-size:var(--dsw-font-markdown-base-font-size) !important}`)
669
- // Inline code is pinned by DSH to 0.875em of the surrounding text (it ignores
670
- // the code token); scale that em by how far the chosen code size sits from
671
- // the stock ratio (a 13px block at this body size).
672
- const stockCodeBlock = value.fontSize * (13 / 16)
615
+ rules.push(`[data-chat-flow-kind="user"] [class*="_bubble"],[data-chat-flow-kind="steering"] [class*="_bubble"],[data-pending-steering] [class*="_bubble"]{font-size:var(--dsw-font-markdown-base-font-size) !important;line-height:var(--dsw-font-markdown-base-line-height) !important}`)
616
+ // Current contenteditable composers and older textarea composers inherit
617
+ // one size/leading pair, including placeholders and measurement layers.
618
+ rules.push(`[data-composer-card]{font:var(--dsw-font-markdown-base) !important}`)
619
+ // Tables use the same body size and leading in both table styles.
620
+ rules.push(`div[data-slot="conversation.chat.node"] [class*="_markdown"]:not([data-markdown-variant="compact"]) table th,div[data-slot="conversation.chat.node"] [class*="_markdown"]:not([data-markdown-variant="compact"]) table td{font-size:var(--dsw-font-markdown-base-font-size) !important;line-height:var(--dsw-font-markdown-base-line-height) !important}`)
621
+ // Inline code normally uses 0.92em. Explicit code sizing follows the
622
+ // same ratio relative to the 14px body / 13px code baseline.
623
+ const stockCodeBlock = value.fontSize * (13 / 14)
673
624
  if (Math.abs(value.codeFontSize - stockCodeBlock) > 0.5) {
674
- const em = (0.875 * (value.codeFontSize / stockCodeBlock)).toFixed(3)
675
- rules.push(`div[data-slot="conversation.chat.node"] div[class*="_markdown_"] :not(pre)>code{font-size:${em}em !important}`)
625
+ const em = (0.92 * (value.codeFontSize / stockCodeBlock)).toFixed(3)
626
+ rules.push(`div[data-slot="conversation.chat.node"] div[class*="_markdown"]:not([data-markdown-variant="compact"]) :not(pre)>code{font-size:${em}em !important}`)
676
627
  }
677
- // 行高: the base vertical rhythm of the reply area. When it differs from
678
- // the stock 16px, every vertical spacing scales from it proportionally:
679
- // ① gaps between message rows (Think ↔ tool cards like pwsh, user ↔
680
- // assistant) — the chat flow column;
681
- // ② gaps between content blocks inside one assistant reply (Think ↔ text) —
682
- // the Assistant Markdown block container (`Sxvs8a_body`, CSS-module
683
- // hashed, stable within the pinned rc.8 conversation package);
684
- // ③ the markdown typography rhythm: paragraphs (`p` has a stock `margin:
685
- // 16px 0` that must be overridden on both axes, not just bottom, or it
686
- // can never tighten below 16), list margins, list-item gaps (stock 6px),
687
- // paragraphs inside list items (stock 8px), code blocks, headings and
688
- // rules — all scaled from the 行高 unit.
689
- // Text line-height itself scales in buildFontCss above.
628
+ // Custom spacing scales prose without expanding compact thinking.
690
629
  if (value.lineHeight !== DEFAULT_LINE_HEIGHT) {
691
630
  const n = value.lineHeight
692
- const md = `div[data-slot="conversation.chat.node"] div[class*="_markdown_"]`
693
- const liGap = Math.max(2, Math.round((6 * n) / DEFAULT_LINE_HEIGHT))
631
+ const md = `div[data-slot="conversation.chat.node"] div[class*="_markdown"]:not([data-markdown-variant="compact"])`
632
+ const liGap = Math.max(2, Math.round((4 * n) / DEFAULT_LINE_HEIGHT))
694
633
  const liPGap = Math.max(2, Math.round((8 * n) / DEFAULT_LINE_HEIGHT))
695
- rules.push(`div[data-slot="conversation.view"] .Md3f7G_column{gap:${n}px !important}`)
696
- rules.push(`div[data-slot="conversation.chat.node"] .Sxvs8a_body{gap:${n}px !important}`)
697
- rules.push(`${md} p{margin:${n}px 0 !important}`)
698
- rules.push(`${md} ul,${md} ol{margin:${n}px 0 !important}`)
634
+ rules.push(`div[data-slot="conversation.view"] [data-chat-flow]{gap:${n}px !important}`)
635
+ rules.push(`div[data-slot="conversation.chat.node"] [data-assistant-body]{--dsh-assistant-block-gap:${n}px}`)
636
+ rules.push(`${md} p{margin:${Math.round(0.75 * n)}px 0 !important}`)
637
+ rules.push(`${md} ul,${md} ol{margin:${Math.round(0.75 * n)}px 0 !important}`)
699
638
  rules.push(`${md} pre{margin:${n}px 0 !important}`)
700
639
  // Code blocks: the pre's inner vertical padding is stock 16px (shiki
701
640
  // default); scale it so the block's own height follows 行高 too. Only
@@ -711,13 +650,11 @@ function buildRuntimeCss(value: ResolvedTweaks): string {
711
650
  }
712
651
  rules.push(`${md} li:not(:first-child){margin-top:${liGap}px !important}`)
713
652
  rules.push(`${md} li>p{margin:${liPGap}px 0 !important}`)
714
- rules.push(`${md} h1,${md} h2,${md} h3{margin:${2 * n}px 0 ${n}px !important}`)
653
+ rules.push(`${md} h1,${md} h2,${md} h3{margin:${Math.round(1.5 * n)}px 0 ${Math.round(0.75 * n)}px !important}`)
715
654
  rules.push(`${md} h4,${md} h5,${md} h6{margin:${n}px 0 !important}`)
716
655
  rules.push(`${md} hr{margin:${2 * n}px 0 !important}`)
717
656
  rules.push(`${md} blockquote{margin:${n}px 0 0 !important}`)
718
- // Keep DSH's flush edges: the first/last block of a reply has zero outer
719
- // margin (stock `>*:first-child` / `>*:last-child`), so a message starts
720
- // and ends tight and the spacing to the next node stays the column gap.
657
+ // Preserve the first and last block's flush edges.
721
658
  rules.push(`${md} > :first-child{margin-top:0 !important}`)
722
659
  rules.push(`${md} > :last-child{margin-bottom:0 !important}`)
723
660
  }
@@ -0,0 +1,38 @@
1
+ /** Font overrides for the conversation, preserving the live theme families. */
2
+
3
+ /**
4
+ * Apply the UI Tweaks size and spacing preferences to the host's prose tokens.
5
+ * @param fontSize - Selected body size in pixels.
6
+ * @param lineHeight - Vertical-spacing unit; 16 retains the normal rhythm.
7
+ * @param codeFontSize - Selected code-block size in pixels.
8
+ * @returns Theme-token overrides for a runtime stylesheet.
9
+ */
10
+ export function buildFontCss(fontSize: number, lineHeight: number, codeFontSize: number): string {
11
+ const parts: string[] = []
12
+ const base = 'var(--dsw-font-markdown-base-font-family, var(--dsw-font-family))'
13
+ const code = 'var(--dsw-font-markdown-code-block-font-family, var(--ds-font-family-code))'
14
+ const token = (name: string, size: number, leading: number, family: string, weight = 400, style = 'normal'): void => {
15
+ const line = Math.max(12, Math.round(leading * (lineHeight / 16)))
16
+ parts.push(`--${name}:${style} ${weight} ${size}px/${line}px ${family}`)
17
+ parts.push(`--${name}-font-size:${size}px`)
18
+ parts.push(`--${name}-line-height:${line}px`)
19
+ parts.push(`--${name}-font-weight:${weight}`)
20
+ parts.push(`--${name}-font-style:${style}`)
21
+ }
22
+ const bodyLine = fontSize + 10
23
+ token('dsw-font-markdown-base', fontSize, bodyLine, base)
24
+ token('dsw-font-markdown-base-strong', fontSize, bodyLine, base, 600)
25
+ token('dsw-font-markdown-base-italic', fontSize, bodyLine, base, 400, 'italic')
26
+ token('dsw-font-markdown-base-strong-italic', fontSize, bodyLine, base, 600, 'italic')
27
+ token('dsw-font-markdown-h1', fontSize + 4, bodyLine + 4, base, 600)
28
+ token('dsw-font-markdown-h2', fontSize + 2, bodyLine + 2, base, 600)
29
+ token('dsw-font-markdown-h3', fontSize + 1, bodyLine + 1, base, 600)
30
+ token('dsw-font-markdown-h4', fontSize, bodyLine, base, 600)
31
+ token('dsw-font-markdown-table', fontSize, bodyLine, base)
32
+ token('dsw-font-markdown-table-head', fontSize, bodyLine, base, 500)
33
+ token('dsw-font-markdown-code', codeFontSize, codeFontSize + 7,
34
+ 'var(--dsw-font-markdown-code-font-family, var(--ds-font-family-code))')
35
+ token('dsw-font-markdown-code-block', codeFontSize, codeFontSize + 7, code)
36
+ token('dsw-font-markdown-code-block-small', Math.max(8, codeFontSize - 2), codeFontSize + 3, code)
37
+ return `body{${parts.join(';')}}`
38
+ }
package/src/index.ts CHANGED
@@ -54,6 +54,7 @@ import { CodingPlanCatalogue } from './codingplan.ts'
54
54
  import { checkOneUpdate, CHATCODE_UPDATE_PACKAGES, performAllUpdates } from './chatcode-update.ts'
55
55
  import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
56
56
  import { apply as applyUiTweaksHost } from './ui-tweaks/index.ts'
57
+ import { buildPluginMarketApi } from './plugin-market-api.ts'
57
58
 
58
59
  export { Config }
59
60
  export type { SidebarConfig, ResolvedSidebarConfig }
@@ -491,6 +492,7 @@ function buildApi(
491
492
  const { sessionId, cwd } = await cwdOf(payload)
492
493
  return { sessionId, cwd, root: rootLabel(cwd), parent: parentOf(cwd) ?? null }
493
494
  },
495
+ ...buildPluginMarketApi(ctx, cwdOf),
494
496
  'fs.tree': async (payload) => {
495
497
  const { cwd } = await cwdOf(payload)
496
498
  const record = payload as { path?: unknown }
@@ -0,0 +1,42 @@
1
+ /** Shared config plugin routes inside the sidebar's existing trust fence. */
2
+ import type { Context } from './context-types.ts'
3
+ import type { PluginMarketService, PluginMarketScope } from './plugin-market-shared.ts'
4
+ import { requireString, SidebarError } from './wire.ts'
5
+
6
+ export function buildPluginMarketApi(ctx: Pick<Context, 'get'>, cwdOf: (payload: unknown) => Promise<{ cwd: string }>): Record<string, (payload: unknown) => Promise<unknown>> {
7
+ const service = (): PluginMarketService => {
8
+ const value = ctx.get('chatcodePluginMarket', false) as PluginMarketService | undefined
9
+ if (!value) throw new SidebarError('chatcode-unavailable', '插件市场服务未挂载,请更新并启用 ChatCode config 插件。', 503)
10
+ return value
11
+ }
12
+ const scopeOf = (payload: unknown): PluginMarketScope => {
13
+ const scope = requireString(payload, 'scope')
14
+ if (scope !== 'project' && scope !== 'user') throw new SidebarError('bad-request', 'scope must be project or user')
15
+ return scope
16
+ }
17
+ // Settings can be opened before a conversation exists. In that case the
18
+ // host launch directory is the project; never take a sessionless cwd from
19
+ // the browser. Conversations still use the existing authoritative resolver.
20
+ const contextOf = async (payload: unknown): Promise<{ sessionId: string; cwd: string }> => {
21
+ const sessionId = (payload as { sessionId?: unknown } | null)?.sessionId
22
+ if (sessionId === undefined || sessionId === '') return { sessionId: '', cwd: process.cwd() }
23
+ requireString(payload, 'sessionId')
24
+ return { sessionId: sessionId as string, cwd: (await cwdOf(payload)).cwd }
25
+ }
26
+ return {
27
+ 'plugins.context': contextOf,
28
+ 'plugins.marketplaces': () => service().marketplaces(),
29
+ 'plugins.catalog': async payload => {
30
+ const market = service()
31
+ const id = requireString(payload, 'marketplaceId')
32
+ if ((payload as { includeMetadata?: unknown }).includeMetadata !== true) return market.plugins(id)
33
+ return market.catalog ? market.catalog(id) : { plugins: await market.plugins(id), warning: '' }
34
+ },
35
+ 'plugins.installed': async payload => service().installed((await contextOf(payload)).cwd),
36
+ 'plugins.install': async payload => service().install((await contextOf(payload)).cwd, requireString(payload, 'pluginId'), scopeOf(payload)),
37
+ 'plugins.uninstall': async payload => {
38
+ await service().uninstall((await contextOf(payload)).cwd, requireString(payload, 'pluginId'), scopeOf(payload))
39
+ return { ok: true }
40
+ },
41
+ }
42
+ }
@@ -0,0 +1,21 @@
1
+ /** Credential-free structural contract of the config plugin's host service. */
2
+ export type PluginMarketScope = 'project' | 'user'
3
+ export interface PluginMarketplace { id: string; name: string; description: string; official: boolean }
4
+ export interface MarketplacePlugin { id: string; marketplaceId: string; name: string; description: string }
5
+ export interface InstalledMarketplacePlugin extends MarketplacePlugin {
6
+ scope: PluginMarketScope
7
+ projectPath: string
8
+ installPath: string
9
+ version: string
10
+ installedAt: string
11
+ }
12
+ export interface MarketplaceCatalog { marketplaces: PluginMarketplace[]; warning: string }
13
+ export interface PluginCatalog { plugins: MarketplacePlugin[]; warning: string }
14
+ export interface PluginMarketService {
15
+ marketplaces(): Promise<MarketplaceCatalog>
16
+ plugins(marketplaceId: string): Promise<MarketplacePlugin[]>
17
+ catalog?(marketplaceId: string): Promise<PluginCatalog>
18
+ installed(cwd: string): Promise<InstalledMarketplacePlugin[]>
19
+ install(cwd: string, pluginId: string, scope: PluginMarketScope): Promise<InstalledMarketplacePlugin>
20
+ uninstall(cwd: string, pluginId: string, scope: PluginMarketScope): Promise<void>
21
+ }