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

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 (47) hide show
  1. package/README.md +19 -1
  2. package/README_EN.md +19 -1
  3. package/lib/client-editor.js +326 -258
  4. package/lib/client-mermaid.js +312 -259
  5. package/lib/client-registry.js +1392 -605
  6. package/lib/client-terminal.js +328 -260
  7. package/lib/client.js +1352 -565
  8. package/lib/index.js +162 -5
  9. package/lib/types/client/LogoutConfirmDialog.d.ts +5 -0
  10. package/lib/types/client/PluginMarketSettingsSection.d.ts +7 -0
  11. package/lib/types/client/PluginMarketView.d.ts +7 -0
  12. package/lib/types/client/api.d.ts +15 -0
  13. package/lib/types/client/builtins/tabs.d.ts +6 -3
  14. package/lib/types/client/locales.d.ts +23 -0
  15. package/lib/types/client/plugin-market-catalog.d.ts +25 -0
  16. package/lib/types/client/ui-tweaks/branding.d.ts +1 -1
  17. package/lib/types/client/ui-tweaks/index.d.ts +1 -1
  18. package/lib/types/client/ui-tweaks/sidebar-header.d.ts +6 -0
  19. package/lib/types/client/ui-tweaks/typography.d.ts +9 -0
  20. package/lib/types/login-page-script.d.ts +2 -0
  21. package/lib/types/login-route.d.ts +2 -0
  22. package/lib/types/plugin-market-api.d.ts +5 -0
  23. package/lib/types/plugin-market-shared.d.ts +37 -0
  24. package/lib/types/ui-tweaks/config.d.ts +5 -6
  25. package/package.json +1 -1
  26. package/src/client/AccountMenu.tsx +12 -2
  27. package/src/client/LogoutConfirmDialog.tsx +39 -0
  28. package/src/client/PluginMarketSettingsSection.tsx +55 -0
  29. package/src/client/PluginMarketView.module.css +35 -0
  30. package/src/client/PluginMarketView.tsx +154 -0
  31. package/src/client/api.ts +7 -0
  32. package/src/client/builtins/tabs.tsx +16 -3
  33. package/src/client/index.tsx +10 -1
  34. package/src/client/locales-ja.ts +23 -0
  35. package/src/client/locales.ts +48 -0
  36. package/src/client/plugin-market-catalog.ts +90 -0
  37. package/src/client/sidebar.module.css +81 -19
  38. package/src/client/ui-tweaks/branding.tsx +25 -16
  39. package/src/client/ui-tweaks/index.tsx +36 -99
  40. package/src/client/ui-tweaks/sidebar-header.ts +73 -0
  41. package/src/client/ui-tweaks/typography.ts +38 -0
  42. package/src/index.ts +2 -0
  43. package/src/login-page-script.ts +107 -0
  44. package/src/login-route.ts +7 -4
  45. package/src/plugin-market-api.ts +42 -0
  46. package/src/plugin-market-shared.ts +21 -0
  47. package/src/ui-tweaks/config.ts +10 -10
@@ -0,0 +1,107 @@
1
+ /** Standalone sign-in recovery; each document starts once and always keeps a usable reopen action. */
2
+ export const CHATCODE_LOGIN_PAGE_SCRIPT = String.raw`
3
+ (() => {
4
+ const message = document.getElementById('message');
5
+ const button = document.getElementById('login');
6
+ const fallback = document.getElementById('login-fallback');
7
+ const link = document.getElementById('login-link');
8
+ const current = new URL(location.href);
9
+ const returnTo = (() => {
10
+ const value = current.searchParams.get('return');
11
+ if (!value || !value.startsWith('/') || value.startsWith('//')) return '/';
12
+ const target = new URL(value, location.origin);
13
+ return target.origin === location.origin ? target.pathname + target.search + target.hash : '/';
14
+ })();
15
+ let busy = false, checking = false, attempted = false, recheck = false, attempt, popup;
16
+ const call = async name => {
17
+ const response = await fetch('/sidebar/api/' + name, {
18
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
19
+ });
20
+ const data = await response.json();
21
+ if (!response.ok || !data.ok) throw new Error('操作失败');
22
+ return data.value;
23
+ };
24
+ const showMessage = (text, error = false) => {
25
+ message.textContent = text;
26
+ message.classList.toggle('error', error);
27
+ };
28
+ async function login() {
29
+ if (busy) return;
30
+ attempted = true;
31
+ busy = true;
32
+ button.disabled = true;
33
+ button.textContent = '正在打开登录页…';
34
+ let tab = null;
35
+ try { tab = window.open('', '_blank'); if (tab) tab.opener = null; } catch {}
36
+ try {
37
+ if (!attempt) {
38
+ const started = await call('chatcode.start');
39
+ const url = new URL(started.url);
40
+ if (!['http:', 'https:'].includes(url.protocol) || typeof started.sessionId !== 'string') throw new Error('登录地址无效');
41
+ attempt = { sessionId: started.sessionId, url: url.href };
42
+ }
43
+ link.href = attempt.url;
44
+ link.textContent = attempt.url;
45
+ fallback.hidden = false;
46
+ if (tab && !tab.closed) {
47
+ try { tab.location.href = attempt.url; popup = tab; } catch { try { tab.close(); } catch {} }
48
+ }
49
+ showMessage('请在登录页完成登录;若网页已关闭,可随时重新打开。');
50
+ } catch {
51
+ try { if (tab) tab.close(); } catch {}
52
+ showMessage('登录页打开失败,请重试。', true);
53
+ } finally {
54
+ busy = false;
55
+ button.disabled = false;
56
+ button.textContent = attempt ? '重新打开登录页' : '去登录';
57
+ }
58
+ }
59
+ async function status() {
60
+ if (checking || busy) return;
61
+ checking = true;
62
+ const observedAttempt = attempt, observedStarted = attempted;
63
+ try {
64
+ const state = await call('chatcode.status');
65
+ if (busy || attempt !== observedAttempt || attempted !== observedStarted) return;
66
+ if (state.available && state.validation === 'valid' && !state.expired) {
67
+ try { if (popup) popup.close(); window.focus(); } catch {}
68
+ location.replace(returnTo);
69
+ return;
70
+ }
71
+ if (!state.available) {
72
+ recheck = true;
73
+ button.textContent = '重新检测';
74
+ showMessage('登录服务暂不可用,请稍后重试。', true);
75
+ return;
76
+ }
77
+ if (state.configured && state.validation === 'unavailable') {
78
+ recheck = true;
79
+ button.textContent = '重新检测';
80
+ showMessage('暂时无法验证已有账号,请检查网络后重新检测。', true);
81
+ return;
82
+ }
83
+ recheck = false;
84
+ button.textContent = attempt ? '重新打开登录页' : '去登录';
85
+ if (attempt && (state.login?.sessionId !== attempt.sessionId || state.login.state !== 'pending')) {
86
+ attempt = undefined;
87
+ fallback.hidden = true;
88
+ link.removeAttribute('href');
89
+ link.textContent = '';
90
+ button.textContent = '去登录';
91
+ showMessage('登录请求已失效,请重新登录。');
92
+ }
93
+ if (!attempted) await login();
94
+ } catch {
95
+ recheck = true;
96
+ button.textContent = '重新检测';
97
+ showMessage('无法读取登录状态,请稍后重试。', true);
98
+ } finally {
99
+ checking = false;
100
+ if (!busy) button.disabled = false;
101
+ }
102
+ }
103
+ button.addEventListener('click', () => { void (recheck ? status() : login()); });
104
+ void status();
105
+ setInterval(() => { void status(); }, 2000);
106
+ })();
107
+ `
@@ -2,14 +2,17 @@ import { readFile } from 'node:fs/promises'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
5
+ import { CHATCODE_LOGIN_PAGE_SCRIPT } from './login-page-script.ts'
5
6
 
6
7
  const assetPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'logo.svg')
7
8
 
8
- function page(logo: string): string {
9
+ /** Render the standalone login gate with one automatic attempt and persistent manual recovery. */
10
+ export function renderChatcodeLoginPage(logo: string): string {
9
11
  return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>请登录 ChatCode</title><style>
10
12
  *{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;display:grid;place-items:center;font-family:Inter,"Microsoft YaHei",system-ui,sans-serif;color:#172b4d;background:radial-gradient(circle at 18% 18%,#e5edff 0,transparent 30rem),linear-gradient(135deg,#edf2fb,#dfe8f7)}.card{width:min(490px,calc(100vw - 40px));padding:34px 44px 36px;border:1px solid #dce4ef;border-radius:18px;background:#fff;box-shadow:0 20px 48px rgba(36,62,103,.18),0 2px 8px rgba(36,62,103,.06)}.brand{display:flex;align-items:center;justify-content:center;gap:12px;padding-bottom:27px;border-bottom:1px solid #edf0f5}.brand img{width:34px;height:34px}.brand strong{font-size:22px;letter-spacing:-.02em}.content{padding-top:40px;text-align:center}.content h1{margin:0;font-size:28px;line-height:1.3;font-weight:650}.content p{min-height:24px;margin:12px 0 30px;color:#71809a;font-size:14px;line-height:1.7}.action{width:100%;min-height:46px;border:0;border-radius:8px;background:#9B0B09;color:#fff;font:600 15px/1 Inter,"Microsoft YaHei",sans-serif;cursor:pointer;box-shadow:0 8px 18px rgba(155,11,9,.25);transition:transform .18s ease,box-shadow .18s ease,background .18s ease}.action:hover:not(:disabled){transform:translateY(-2px);background:#b20d0a;box-shadow:0 12px 24px rgba(155,11,9,.38)}.action:focus-visible{outline:3px solid #f0b7b5;outline-offset:3px}.action:disabled{cursor:default;opacity:.7}.error{color:#b42318}@media(max-width:480px){.card{padding:30px 26px}.content{padding-top:32px}}
11
- </style></head><body><main class="card"><div class="brand"><img src="data:image/svg+xml;base64,${logo}" alt=""><strong>ChatCode</strong></div><section class="content"><h1>请登录 ChatCode</h1><p id="message">请登录后继续使用。</p><button id="login" class="action" type="button">去登录</button></section></main><script>
12
- const message=document.getElementById('message'),button=document.getElementById('login');const current=new URL(location.href);const returnTo=(()=>{const v=current.searchParams.get('return');return v&&v.startsWith('/')&&!v.startsWith('//')?v:'/'} )();const call=async(name,body={})=>{const r=await fetch('/sidebar/api/'+name,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok||!d.ok)throw new Error(d.error?.message||'操作失败');return d.value};let waiting=false;async function status(){try{const s=await call('chatcode.status');if(s.available&&s.validation==='valid'&&!s.expired){location.replace(returnTo);return}if(waiting)message.textContent='正在等待浏览器完成登录…'}catch{message.textContent='无法读取登录状态,请稍后重试。';message.classList.add('error')}}async function login(){const tab=window.open('','_blank');button.disabled=true;button.textContent='正在打开登录页…';message.classList.remove('error');try{const s=await call('chatcode.start');waiting=true;if(tab&&!tab.closed){tab.opener=null;tab.location.href=s.url;message.textContent='请在新打开的浏览器窗口完成登录…'}else{message.innerHTML='浏览器拦截了登录窗口,请 <a href="'+s.url+'" target="_blank" rel="noopener noreferrer">打开登录页</a> 后完成登录。'}}catch{try{tab&&tab.close()}catch{}button.disabled=false;button.textContent='去登录';message.textContent='登录页打开失败,请重试。';message.classList.add('error')}}button.addEventListener('click',login);status();setInterval(status,2000);
13
+ .login-fallback{margin:18px 0 0;color:#71809a;font-size:12px;line-height:1.7;overflow-wrap:anywhere}.login-fallback a{display:block;margin-top:4px;color:#315da8;text-underline-offset:3px}
14
+ </style></head><body><main class="card"><div class="brand"><img src="data:image/svg+xml;base64,${logo}" alt=""><strong>ChatCode</strong></div><section class="content"><h1>请登录 ChatCode</h1><p id="message" aria-live="polite">正在检查登录状态…</p><button id="login" class="action" type="button" disabled>正在检查登录状态…</button><div id="login-fallback" class="login-fallback" hidden>若登录网页已关闭或未弹出,可再次访问:<a id="login-link" target="_blank" rel="noopener noreferrer"></a></div></section></main><script>
15
+ ${CHATCODE_LOGIN_PAGE_SCRIPT}
13
16
  </script></body></html>`
14
17
  }
15
18
 
@@ -20,7 +23,7 @@ export function registerLoginRoute(ctx: Context, fence: (req: SidebarHttpRequest
20
23
  try {
21
24
  const logo = (await readFile(assetPath)).toString('base64')
22
25
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': "default-src 'self'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'" })
23
- res.end(req.method === 'HEAD' ? undefined : page(logo))
26
+ res.end(req.method === 'HEAD' ? undefined : renderChatcodeLoginPage(logo))
24
27
  } catch { res.writeHead(500); res.end('login page unavailable') }
25
28
  } })
26
29
  }
@@ -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
+ }
@@ -29,10 +29,9 @@ export interface UITweaksConfig {
29
29
  */
30
30
  codeFontScale?: number
31
31
  /**
32
- * Absolute code font size in px (8–32). 13 matches the stock DSH code block
33
- * at a 16px body. Drives the code block directly; inline code and the small
34
- * code font follow proportionally. When unset, falls back to the legacy
35
- * `codeFontScale` percentage, then to the stock default.
32
+ * Absolute code font size in px (8–32), default 13. Drives code blocks;
33
+ * inline code and small code follow proportionally. When unset, uses a non-default legacy
34
+ * `codeFontScale` percentage, otherwise 13px.
36
35
  */
37
36
  codeFontSize?: number
38
37
  /**
@@ -114,7 +113,7 @@ export interface UITweaksConfig {
114
113
 
115
114
  export const MIN_FONT_SIZE = 10
116
115
  export const MAX_FONT_SIZE = 32
117
- export const DEFAULT_FONT_SIZE = 16
116
+ export const DEFAULT_FONT_SIZE = 14
118
117
 
119
118
  /** 16px matches the stock DSH vertical rhythm (message/block gaps, etc.). */
120
119
  export const MIN_LINE_HEIGHT = 0
@@ -126,7 +125,7 @@ export const MIN_CODE_FONT_SCALE = 50
126
125
  export const MAX_CODE_FONT_SCALE = 150
127
126
  export const DEFAULT_CODE_FONT_SCALE = 81
128
127
 
129
- /** Absolute code font size (px): 13 is the stock DSH code block at a 16px body. */
128
+ /** Default absolute code-block size, paired with 14px prose. */
130
129
  export const MIN_CODE_FONT_SIZE = 8
131
130
  export const MAX_CODE_FONT_SIZE = 32
132
131
  export const DEFAULT_CODE_FONT_SIZE = 13
@@ -164,7 +163,7 @@ export const DEFAULT_NOTIFY_SOUND = false
164
163
 
165
164
  /** Configuration schema with documented defaults. */
166
165
  export const Config: Schema<UITweaksConfig> = z.object({
167
- fontSize: z.number().min(10).max(32).default(16),
166
+ fontSize: z.number().min(10).max(32).default(DEFAULT_FONT_SIZE),
168
167
  codeFontScale: z.number().min(MIN_CODE_FONT_SCALE).max(MAX_CODE_FONT_SCALE).default(DEFAULT_CODE_FONT_SCALE),
169
168
  codeFontSize: z.number().min(MIN_CODE_FONT_SIZE).max(MAX_CODE_FONT_SIZE),
170
169
  lineHeight: z.number().min(MIN_LINE_HEIGHT).max(MAX_LINE_HEIGHT).default(DEFAULT_LINE_HEIGHT),
@@ -229,11 +228,12 @@ export interface ResolvedUITweaksConfig {
229
228
  export function resolveConfig(config: UITweaksConfig = {}): ResolvedUITweaksConfig {
230
229
  const fontSize = config.fontSize ?? DEFAULT_FONT_SIZE
231
230
  const codeFontScale = config.codeFontScale ?? DEFAULT_CODE_FONT_SCALE
232
- // Effective code size: the absolute px input wins; otherwise derive px from
233
- // the legacy percentage at the resolved body size; otherwise stock.
231
+ // Explicit code sizing wins; the stock preference keeps readable 13px code.
234
232
  const codeFontSize = typeof config.codeFontSize === 'number'
235
233
  ? Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, config.codeFontSize))
236
- : Math.max(8, Math.round(fontSize * (13 / 16) * (codeFontScale / DEFAULT_CODE_FONT_SCALE)))
234
+ : codeFontScale === DEFAULT_CODE_FONT_SCALE
235
+ ? DEFAULT_CODE_FONT_SIZE
236
+ : Math.max(8, Math.round(fontSize * (13 / 16) * (codeFontScale / DEFAULT_CODE_FONT_SCALE)))
237
237
  const lineHeight = config.lineHeight ?? DEFAULT_LINE_HEIGHT
238
238
  const tableStyle = config.tableStyle ?? 'default'
239
239
  const gitBarEnabled = config.gitBarEnabled ?? DEFAULT_GITBAR_ENABLED