@mohou/panel 1.0.20

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 (113) hide show
  1. package/README.md +8 -0
  2. package/lib/types/chrome/hue.d.ts +3 -0
  3. package/lib/types/chrome/hue.d.ts.map +1 -0
  4. package/lib/types/gallery/card-style.d.ts +8 -0
  5. package/lib/types/gallery/card-style.d.ts.map +1 -0
  6. package/lib/types/gallery/client.d.ts +20 -0
  7. package/lib/types/gallery/client.d.ts.map +1 -0
  8. package/lib/types/gallery/desk.d.ts +16 -0
  9. package/lib/types/gallery/desk.d.ts.map +1 -0
  10. package/lib/types/gallery/list.d.ts +37 -0
  11. package/lib/types/gallery/list.d.ts.map +1 -0
  12. package/lib/types/gallery/sort.d.ts +24 -0
  13. package/lib/types/gallery/sort.d.ts.map +1 -0
  14. package/lib/types/gallery/state.d.ts +90 -0
  15. package/lib/types/gallery/state.d.ts.map +1 -0
  16. package/lib/types/gallery/tabs.d.ts +45 -0
  17. package/lib/types/gallery/tabs.d.ts.map +1 -0
  18. package/lib/types/gallery/view.d.ts +57 -0
  19. package/lib/types/gallery/view.d.ts.map +1 -0
  20. package/lib/types/history/client.d.ts +25 -0
  21. package/lib/types/history/client.d.ts.map +1 -0
  22. package/lib/types/history/state.d.ts +39 -0
  23. package/lib/types/history/state.d.ts.map +1 -0
  24. package/lib/types/history/view.d.ts +15 -0
  25. package/lib/types/history/view.d.ts.map +1 -0
  26. package/lib/types/index.d.ts +24 -0
  27. package/lib/types/index.d.ts.map +1 -0
  28. package/lib/types/labels.d.ts +267 -0
  29. package/lib/types/labels.d.ts.map +1 -0
  30. package/lib/types/settings/author-mcp-view.d.ts +15 -0
  31. package/lib/types/settings/author-mcp-view.d.ts.map +1 -0
  32. package/lib/types/settings/author-mcp.d.ts +4 -0
  33. package/lib/types/settings/author-mcp.d.ts.map +1 -0
  34. package/lib/types/settings/client.d.ts +198 -0
  35. package/lib/types/settings/client.d.ts.map +1 -0
  36. package/lib/types/settings/credentials-view.d.ts +15 -0
  37. package/lib/types/settings/credentials-view.d.ts.map +1 -0
  38. package/lib/types/settings/form.d.ts +27 -0
  39. package/lib/types/settings/form.d.ts.map +1 -0
  40. package/lib/types/settings/install-row.d.ts +10 -0
  41. package/lib/types/settings/install-row.d.ts.map +1 -0
  42. package/lib/types/settings/mcp-view.d.ts +14 -0
  43. package/lib/types/settings/mcp-view.d.ts.map +1 -0
  44. package/lib/types/settings/skill-dirs.d.ts +6 -0
  45. package/lib/types/settings/skill-dirs.d.ts.map +1 -0
  46. package/lib/types/settings/skill-install.d.ts +13 -0
  47. package/lib/types/settings/skill-install.d.ts.map +1 -0
  48. package/lib/types/settings/state.d.ts +94 -0
  49. package/lib/types/settings/state.d.ts.map +1 -0
  50. package/lib/types/settings/update-dialog.d.ts +56 -0
  51. package/lib/types/settings/update-dialog.d.ts.map +1 -0
  52. package/lib/types/settings/view.d.ts +26 -0
  53. package/lib/types/settings/view.d.ts.map +1 -0
  54. package/lib/types/storage/client.d.ts +15 -0
  55. package/lib/types/storage/client.d.ts.map +1 -0
  56. package/lib/types/storage/state.d.ts +42 -0
  57. package/lib/types/storage/state.d.ts.map +1 -0
  58. package/lib/types/storage/view.d.ts +16 -0
  59. package/lib/types/storage/view.d.ts.map +1 -0
  60. package/lib/types/surface/state.d.ts +42 -0
  61. package/lib/types/surface/state.d.ts.map +1 -0
  62. package/lib/types/surface/view.d.ts +34 -0
  63. package/lib/types/surface/view.d.ts.map +1 -0
  64. package/lib/types/theme/client.d.ts +45 -0
  65. package/lib/types/theme/client.d.ts.map +1 -0
  66. package/lib/types/theme/menu.d.ts +25 -0
  67. package/lib/types/theme/menu.d.ts.map +1 -0
  68. package/lib/types/theme/state.d.ts +46 -0
  69. package/lib/types/theme/state.d.ts.map +1 -0
  70. package/lib/types/theme/view.d.ts +16 -0
  71. package/lib/types/theme/view.d.ts.map +1 -0
  72. package/lib/types/ui/dialog.d.ts +12 -0
  73. package/lib/types/ui/dialog.d.ts.map +1 -0
  74. package/lib/types/ui/tooltip.d.ts +12 -0
  75. package/lib/types/ui/tooltip.d.ts.map +1 -0
  76. package/package.json +38 -0
  77. package/src/chrome/hue.ts +6 -0
  78. package/src/gallery/card-style.ts +27 -0
  79. package/src/gallery/client.ts +26 -0
  80. package/src/gallery/desk.tsx +54 -0
  81. package/src/gallery/list.ts +54 -0
  82. package/src/gallery/sort.ts +72 -0
  83. package/src/gallery/state.ts +229 -0
  84. package/src/gallery/tabs.ts +85 -0
  85. package/src/gallery/view.tsx +501 -0
  86. package/src/history/client.ts +27 -0
  87. package/src/history/state.ts +90 -0
  88. package/src/history/view.tsx +131 -0
  89. package/src/index.ts +61 -0
  90. package/src/labels.ts +536 -0
  91. package/src/settings/author-mcp-view.tsx +154 -0
  92. package/src/settings/author-mcp.ts +24 -0
  93. package/src/settings/client.ts +205 -0
  94. package/src/settings/credentials-view.tsx +195 -0
  95. package/src/settings/form.ts +27 -0
  96. package/src/settings/install-row.tsx +35 -0
  97. package/src/settings/mcp-view.tsx +793 -0
  98. package/src/settings/skill-dirs.ts +41 -0
  99. package/src/settings/skill-install.tsx +134 -0
  100. package/src/settings/state.ts +225 -0
  101. package/src/settings/update-dialog.tsx +207 -0
  102. package/src/settings/view.tsx +722 -0
  103. package/src/storage/client.ts +16 -0
  104. package/src/storage/state.ts +94 -0
  105. package/src/storage/view.tsx +190 -0
  106. package/src/surface/state.ts +72 -0
  107. package/src/surface/view.tsx +255 -0
  108. package/src/theme/client.ts +34 -0
  109. package/src/theme/menu.tsx +286 -0
  110. package/src/theme/state.ts +113 -0
  111. package/src/theme/view.tsx +73 -0
  112. package/src/ui/dialog.tsx +25 -0
  113. package/src/ui/tooltip.tsx +19 -0
@@ -0,0 +1,154 @@
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+
3
+ import { Dialog } from '../ui/dialog.tsx'
4
+ import { Tooltip } from '../ui/tooltip.tsx'
5
+ import { readAuthorMcpAgentIds, writeAuthorMcpAgentIds } from './author-mcp.ts'
6
+ import type { PanelAbout, PanelAuthorMcpStatus } from './client.ts'
7
+ import { installingCaption, shortHomePath, toggleId } from './install-row.tsx'
8
+
9
+ /**
10
+ * Authoring MCP dest list. One-click merge into assistant MCP files.
11
+ * Generate-snippet stays as a fallback.
12
+ * @param props - status loaders, about reader, and chrome labels
13
+ */
14
+ export function AuthorMcpInstall(props: {
15
+ readonly label: (key: string) => string
16
+ readonly readAbout?: () => Promise<PanelAbout>
17
+ readonly readAuthorMcp?: () => Promise<PanelAuthorMcpStatus>
18
+ readonly installAuthorMcp?: (agentIds: readonly string[], description: string) => Promise<PanelAuthorMcpStatus>
19
+ readonly revealAuthorMcp?: (dest: string) => Promise<void>
20
+ }): ReactNode {
21
+ const [status, setStatus] = useState<PanelAuthorMcpStatus | undefined>(undefined)
22
+ const [busy, setBusy] = useState(false)
23
+ const [failed, setFailed] = useState(false)
24
+ const [picked, setPicked] = useState<ReadonlySet<string>>(() => new Set(readAuthorMcpAgentIds() ?? []))
25
+ const [snippetOpen, setSnippetOpen] = useState(false)
26
+ const [about, setAbout] = useState<PanelAbout | undefined>(undefined)
27
+ useEffect(() => {
28
+ if (props.readAuthorMcp === undefined) return
29
+ void props.readAuthorMcp().then((next) => {
30
+ setStatus(next)
31
+ if (readAuthorMcpAgentIds() === undefined) {
32
+ setPicked(new Set(next.agents.filter(agent => agent.homePresent).map(agent => agent.id)))
33
+ }
34
+ }, () => setStatus(undefined))
35
+ }, [props.readAuthorMcp])
36
+ if (props.readAuthorMcp === undefined && props.readAbout === undefined) return null
37
+ return (
38
+ <div className="rounded-2xl border bg-card px-4 py-3">
39
+ <p className="m-0 text-sm font-medium">{props.label('mcp-authoring')}</p>
40
+ <p className="mt-1 mb-3 text-xs text-muted-foreground">{props.label('mcp-authoring-help')}</p>
41
+ {props.readAuthorMcp === undefined ? null : (
42
+ <ul className="m-0 mb-3 flex list-none flex-col gap-1 p-0">
43
+ {(status?.agents ?? []).map(agent => (
44
+ <li key={agent.id} className={agent.homePresent ? 'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-muted' : 'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 opacity-50 hover:bg-muted'}>
45
+ <input type="checkbox" checked={picked.has(agent.id)} onChange={() => {
46
+ const next = toggleId(picked, agent.id)
47
+ setPicked(next)
48
+ writeAuthorMcpAgentIds([...next])
49
+ }} />
50
+ <span className="text-sm">{agent.label}</span>
51
+ <span className="ml-4 flex min-w-0 items-center gap-2">
52
+ {agent.homePresent ? null : <span className="text-[11px] text-muted-foreground">{props.label('skill-missing-home')}</span>}
53
+ {busy && picked.has(agent.id) ? installingCaption(props.label) : authorMcpCaption(agent, props.label)}
54
+ {agent.installed && props.revealAuthorMcp !== undefined ? (
55
+ <Tooltip text={agent.dest}><button type="button" className="max-w-[28rem] truncate font-mono text-[11px] text-muted-foreground underline-offset-2 hover:underline" onClick={() => { void props.revealAuthorMcp?.(agent.dest) }}>{shortHomePath(agent.dest)}</button></Tooltip>
56
+ ) : null}
57
+ </span>
58
+ {agent.adapter === undefined ? null : (
59
+ <span className="ml-auto shrink-0 rounded-full border px-2 py-0.5 font-mono text-[10px] tracking-normal text-muted-foreground" data-adapter={agent.adapter}>{agent.adapter}</span>
60
+ )}
61
+ </li>
62
+ ))}
63
+ </ul>
64
+ )}
65
+ <div className="flex flex-wrap gap-2">
66
+ {props.installAuthorMcp === undefined ? null : (
67
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm hover:bg-muted disabled:opacity-60" disabled={busy} onClick={() => {
68
+ const install = props.installAuthorMcp
69
+ if (install === undefined) return
70
+ setBusy(true)
71
+ setFailed(false)
72
+ void install([...picked], props.label('mcp-authoring-blurb')).then(setStatus, () => setFailed(true)).finally(() => setBusy(false))
73
+ }}>{busy ? '…' : props.label(authorMcpNeedsUpdate(status, picked) ? 'mcp-authoring-update-install' : 'mcp-authoring-install')}</button>
74
+ )}
75
+ {props.readAbout === undefined ? null : (
76
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm hover:bg-muted" onClick={() => {
77
+ const read = props.readAbout
78
+ if (read === undefined) return
79
+ void read().then((value) => {
80
+ setAbout(value)
81
+ setSnippetOpen(true)
82
+ }, () => setAbout(undefined))
83
+ }}>{props.label('mcp-authoring-generate')}</button>
84
+ )}
85
+ </div>
86
+ {failed ? <p className="mt-2 mb-0 text-xs text-destructive">{props.label('mcp-authoring-failed')}</p> : null}
87
+ {snippetOpen && about !== undefined ? (
88
+ <AuthoringDialog about={about} label={props.label} onClose={() => setSnippetOpen(false)} />
89
+ ) : null}
90
+ </div>
91
+ )
92
+ }
93
+
94
+ function authorMcpCaption(
95
+ row: { readonly installed: boolean; readonly updateAvailable: boolean },
96
+ label: (key: string) => string,
97
+ ): ReactNode {
98
+ if (!row.installed) return null
99
+ return (
100
+ <span className={row.updateAvailable ? 'text-[11px] text-primary' : 'text-[11px] text-muted-foreground'}>
101
+ {label(row.updateAvailable ? 'skill-update' : 'skill-installed')}
102
+ </span>
103
+ )
104
+ }
105
+
106
+ function authorMcpNeedsUpdate(status: PanelAuthorMcpStatus | undefined, picked: ReadonlySet<string>): boolean {
107
+ if (status === undefined) return false
108
+ return status.agents.some(agent => picked.has(agent.id) && agent.updateAvailable)
109
+ }
110
+
111
+ function AuthoringDialog(props: {
112
+ readonly about: PanelAbout
113
+ readonly label: (key: string) => string
114
+ readonly onClose: () => void
115
+ }): ReactNode {
116
+ const [copied, setCopied] = useState(false)
117
+ const snippet = authoringSnippet(props.about.authoring.url, props.about.authoring.token, props.label('mcp-authoring-blurb'))
118
+ return (
119
+ <Dialog width="wide" onClose={props.onClose}>
120
+ <h5 className="m-0 text-base font-semibold">{props.label('mcp-authoring')}</h5>
121
+ <p className="mt-1 text-sm text-muted-foreground">{props.label('mcp-authoring-help')}</p>
122
+ <ul className="mt-4 max-h-56 overflow-auto rounded-xl border bg-muted/40 p-2">
123
+ {props.about.authoring.tools.map(tool => (
124
+ <li key={tool.name} className="rounded-lg px-3 py-2">
125
+ <p className="m-0 font-mono text-sm font-medium">{tool.name}</p>
126
+ <p className="mt-0.5 mb-0 text-xs leading-5 text-muted-foreground">{tool.description}</p>
127
+ </li>
128
+ ))}
129
+ </ul>
130
+ <pre className="mt-4 overflow-auto rounded-xl border bg-muted p-3 font-mono text-xs">{snippet}</pre>
131
+ <div className="mt-3 flex justify-end gap-2">
132
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={() => {
133
+ void navigator.clipboard.writeText(snippet).then(() => {
134
+ setCopied(true)
135
+ window.setTimeout(() => setCopied(false), 1600)
136
+ })
137
+ }}>{copied ? props.label('mcp-copied') : props.label('mcp-copy')}</button>
138
+ <button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={props.onClose}>{props.label('confirm')}</button>
139
+ </div>
140
+ </Dialog>
141
+ )
142
+ }
143
+
144
+ function authoringSnippet(url: string, token: string, description: string): string {
145
+ return `${JSON.stringify({
146
+ 'mini-app': {
147
+ url,
148
+ transport: 'streamable-http',
149
+ description,
150
+ headers: { Authorization: `Bearer ${token}` },
151
+ _monkeyagent: { description },
152
+ },
153
+ }, null, 2)}\n`
154
+ }
@@ -0,0 +1,24 @@
1
+ const selectedKey = 'mini-app.panel.mcp-agents'
2
+
3
+ /** Assistants remembered for the authoring MCP install. */
4
+ export function readAuthorMcpAgentIds(): string[] | undefined {
5
+ if (typeof localStorage === 'undefined') return undefined
6
+ try {
7
+ const raw = localStorage.getItem(selectedKey)
8
+ if (raw === null || raw.length === 0) return undefined
9
+ const parsed = JSON.parse(raw) as unknown
10
+ if (!Array.isArray(parsed)) return undefined
11
+ const ids = parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
12
+ return ids.length === 0 ? undefined : ids
13
+ } catch {
14
+ return undefined
15
+ }
16
+ }
17
+
18
+ export function writeAuthorMcpAgentIds(ids: readonly string[]): void {
19
+ try {
20
+ localStorage.setItem(selectedKey, JSON.stringify([...ids]))
21
+ } catch {
22
+ // A failed write still changes the current view.
23
+ }
24
+ }
@@ -0,0 +1,205 @@
1
+ /** Public host policy the settings form can read. No secrets. */
2
+ export interface PanelPolicy {
3
+ readonly theme: 'system' | 'light' | 'dark'
4
+ readonly palette: string
5
+ readonly locale: string
6
+ readonly chatLanguage: string
7
+ readonly hostPort: number
8
+ readonly llm: { readonly provider: string; readonly model: string } | null
9
+ readonly runtimeProvider: { readonly id: string }
10
+ readonly defaultWorkbenchId?: string
11
+ }
12
+
13
+ export interface PanelPolicyWrite {
14
+ readonly policy: PanelPolicy
15
+ readonly restartRequired: boolean
16
+ }
17
+
18
+ export interface PanelProbe {
19
+ readonly healthy: boolean
20
+ readonly code?: string
21
+ readonly message?: string
22
+ }
23
+
24
+ /** Writing skill install status for one assistant or custom folder. */
25
+ export interface PanelSkillCopy {
26
+ readonly dest: string
27
+ readonly installed: boolean
28
+ readonly version: string | null
29
+ readonly updateAvailable: boolean
30
+ }
31
+
32
+ export interface PanelSkillAgent extends PanelSkillCopy {
33
+ readonly id: string
34
+ readonly label: string
35
+ readonly skillsDir: string
36
+ readonly homePresent: boolean
37
+ }
38
+
39
+ export interface PanelSkillStatus {
40
+ readonly skillId: string
41
+ readonly version: string | null
42
+ readonly agents: readonly PanelSkillAgent[]
43
+ readonly customs: readonly (PanelSkillCopy & { readonly dir: string })[]
44
+ }
45
+
46
+ /** Authoring MCP install status for one assistant. */
47
+ export interface PanelAuthorMcpAgent {
48
+ readonly id: string
49
+ readonly label: string
50
+ readonly dest: string
51
+ readonly homePresent: boolean
52
+ readonly installed: boolean
53
+ readonly updateAvailable: boolean
54
+ readonly adapter?: string
55
+ }
56
+
57
+ export interface PanelAuthorMcpStatus {
58
+ readonly agents: readonly PanelAuthorMcpAgent[]
59
+ }
60
+
61
+ /** Owner about block, including the authoring MCP snippet. */
62
+ export interface PanelAbout {
63
+ readonly name: string
64
+ readonly current: string
65
+ readonly platform: string
66
+ readonly authoring: {
67
+ readonly url: string
68
+ readonly token: string
69
+ readonly tools: readonly { readonly name: string; readonly description: string }[]
70
+ }
71
+ }
72
+
73
+ /** Why an install did not finish. Closed: the panel owns the wording of each code. */
74
+ export const panelUpdateFailureCodes = ['timeout', 'exit', 'prepare', 'closed', 'leftover', 'verify', 'boot'] as const
75
+
76
+ export type PanelUpdateFailureCode = (typeof panelUpdateFailureCodes)[number]
77
+
78
+ /**
79
+ * What the launcher recorded about the last install, in the runtime root.
80
+ * `from` is the version that was running when the attempt started, and `at` is epoch milliseconds.
81
+ */
82
+ export type PanelUpdateAttempt =
83
+ | {
84
+ readonly state: 'done'
85
+ readonly from?: string
86
+ readonly to?: string
87
+ readonly at: number
88
+ }
89
+ | {
90
+ readonly state: 'failed'
91
+ readonly code: PanelUpdateFailureCode
92
+ readonly from?: string
93
+ readonly to?: string
94
+ readonly rolledBack: boolean
95
+ readonly exitCode?: number
96
+ readonly log?: string
97
+ readonly at: number
98
+ }
99
+
100
+ /** One registry check. `latest` is missing when the registry did not answer. */
101
+ export interface PanelUpdateCheck {
102
+ readonly name: string
103
+ readonly current: string
104
+ readonly latest: string | null
105
+ readonly updateAvailable: boolean
106
+ readonly channel?: 'registry' | 'tarball'
107
+ readonly installable?: boolean
108
+ readonly error?: string
109
+ /** The last install this machine recorded, when one is on disk. The panel shows it once. */
110
+ readonly lastAttempt?: PanelUpdateAttempt
111
+ }
112
+
113
+ /** One registered brain and the vendor/model pairs it publishes. */
114
+ export interface PanelRuntime {
115
+ readonly id: string
116
+ readonly label?: string
117
+ readonly models: readonly { readonly provider: string; readonly models: readonly string[] }[]
118
+ }
119
+
120
+ /** Settings calls. Absent means the settings entry is hidden. No route string lives here. */
121
+ export interface PanelSettingsClient {
122
+ readPolicy(): Promise<PanelPolicy>
123
+ writePolicy(policy: PanelPolicy): Promise<PanelPolicyWrite>
124
+ probe(id: string): Promise<PanelProbe>
125
+ readAbout?(): Promise<PanelAbout>
126
+ checkUpdate?(): Promise<PanelUpdateCheck>
127
+ installUpdate?(version: string): Promise<void>
128
+ /** Tell Host the launcher's last attempt was shown, so the next check does not open it again. */
129
+ ackUpdate?(at: number): Promise<void>
130
+ restartHost?(): Promise<void>
131
+ listRuntimes?(): Promise<readonly PanelRuntime[]>
132
+ readSkill?(customDirs?: readonly string[]): Promise<PanelSkillStatus>
133
+ installSkill?(agentIds: readonly string[], customDirs: readonly string[]): Promise<PanelSkillStatus>
134
+ revealSkill?(dest: string): Promise<void>
135
+ readAuthorMcp?(): Promise<PanelAuthorMcpStatus>
136
+ installAuthorMcp?(agentIds: readonly string[], description: string): Promise<PanelAuthorMcpStatus>
137
+ revealAuthorMcp?(dest: string): Promise<void>
138
+ /** Names, descriptions, and whether the store accepts a write. A secret never crosses this. */
139
+ readCredentials?(): Promise<PanelCredentials>
140
+ /**
141
+ * Save one account. An omitted secret keeps the one already stored, which is how the form edits a
142
+ * description without the owner pasting a secret again. Host reads the stored value; the panel
143
+ * never receives one.
144
+ */
145
+ putCredential?(name: string, description: string, secret?: string): Promise<void>
146
+ removeCredential?(name: string): Promise<void>
147
+ listMcp?(): Promise<PanelMcpList>
148
+ writeMcp?(servers: readonly McpServerDraft[]): Promise<{ unresolved: readonly PanelMcpFailure[] }>
149
+ checkMcp?(server: McpServerDraft): Promise<McpCheckResult>
150
+ admitMcp?(text: string): Promise<readonly McpServerDraft[]>
151
+ importMcp?(source: string): Promise<readonly McpServerDraft[]>
152
+ }
153
+
154
+ /** One stored account, as the editor lists it. No secret: values are write-only. */
155
+ export interface PanelCredential {
156
+ readonly name: string
157
+ readonly description: string
158
+ }
159
+
160
+ export interface PanelCredentials {
161
+ readonly credentials: readonly PanelCredential[]
162
+ /** False when the store is read-only, and then the form shows no write control. */
163
+ readonly writable: boolean
164
+ }
165
+
166
+ /** One server the last boot left out, and why. */
167
+ export interface PanelMcpFailure {
168
+ readonly id: string
169
+ readonly code: string
170
+ readonly message: string
171
+ }
172
+
173
+ export interface PanelMcpList {
174
+ readonly servers: readonly McpServerDraft[]
175
+ readonly unresolved: readonly PanelMcpFailure[]
176
+ }
177
+
178
+ /** One MCP server the settings section can edit. */
179
+ export interface McpServerDraft {
180
+ readonly id: string
181
+ readonly description?: string
182
+ readonly enabled?: boolean
183
+ readonly command?: string
184
+ readonly args?: readonly string[]
185
+ readonly env?: Readonly<Record<string, string>>
186
+ readonly url?: string
187
+ readonly transport?: 'sse' | 'streamable-http'
188
+ readonly headers?: Readonly<Record<string, string>>
189
+ }
190
+
191
+ /** One tool a check listed, with the schemas the server declared. */
192
+ export interface McpToolRow {
193
+ readonly name: string
194
+ readonly description?: string
195
+ readonly inputSchema?: Record<string, unknown>
196
+ readonly outputSchema?: Record<string, unknown>
197
+ }
198
+
199
+ /** Result of trying one server without saving. */
200
+ export interface McpCheckResult {
201
+ readonly ok: boolean
202
+ readonly tools: readonly McpToolRow[]
203
+ readonly code?: string
204
+ readonly message?: string
205
+ }
@@ -0,0 +1,195 @@
1
+ import { KeyRound } from 'lucide-react'
2
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
3
+
4
+ import { panelText, type PanelLabelMode, type PanelLocale } from '../labels.ts'
5
+ import { Dialog } from '../ui/dialog.tsx'
6
+ import type { PanelCredential, PanelSettingsClient } from './client.ts'
7
+
8
+ interface Draft {
9
+ readonly name: string
10
+ readonly description: string
11
+ readonly secret: string
12
+ /** The name being replaced, when the form edits a row instead of adding one. */
13
+ readonly editing: string | undefined
14
+ }
15
+
16
+ const emptyDraft: Draft = { name: '', description: '', secret: '', editing: undefined }
17
+
18
+ /**
19
+ * Credentials settings. The list carries names and descriptions only: a secret is written once and
20
+ * never read back, so the form has no field that can show an existing value.
21
+ * A read-only store keeps the list and hides every write control.
22
+ * @param props - settings client and chrome locale
23
+ */
24
+ export function CredentialSettings(props: {
25
+ readonly client: PanelSettingsClient
26
+ readonly locale: PanelLocale
27
+ readonly mode: PanelLabelMode
28
+ }): ReactNode {
29
+ const label = (key: string) => panelText(props.locale, key, props.mode)
30
+ const [rows, setRows] = useState<readonly PanelCredential[]>([])
31
+ const [writable, setWritable] = useState(false)
32
+ const [draft, setDraft] = useState<Draft | undefined>(undefined)
33
+ const [removing, setRemoving] = useState<string | undefined>(undefined)
34
+ const [busy, setBusy] = useState(false)
35
+ const [failed, setFailed] = useState('')
36
+ const [notice, setNotice] = useState('')
37
+ const loaded = useRef(false)
38
+ const read = props.client.readCredentials
39
+
40
+ useEffect(() => {
41
+ if (read === undefined || loaded.current) return
42
+ loaded.current = true
43
+ const client = props.client
44
+ void read.call(client).then((next) => {
45
+ setRows(next.credentials)
46
+ setWritable(next.writable)
47
+ }, () => setFailed(label('credentials-load-failed')))
48
+ }, [read, props.client, label])
49
+
50
+ if (read === undefined) return <p className="text-sm text-muted-foreground">{label('credentials-empty')}</p>
51
+
52
+ const refresh = async (): Promise<void> => {
53
+ const next = await read.call(props.client)
54
+ setRows(next.credentials)
55
+ setWritable(next.writable)
56
+ }
57
+
58
+ const save = async (value: Draft): Promise<void> => {
59
+ const put = props.client.putCredential
60
+ if (put === undefined) return
61
+ setBusy(true)
62
+ setFailed('')
63
+ try {
64
+ // Blank means "keep the stored secret": Host resolves it, and the panel has no value to send.
65
+ const secret = value.secret.length === 0 ? undefined : value.secret
66
+ await put.call(props.client, value.name.trim(), value.description.trim(), secret)
67
+ await refresh()
68
+ setDraft(undefined)
69
+ setNotice(label(secret === undefined ? 'credentials-saved-description' : 'credentials-saved'))
70
+ } catch {
71
+ setFailed(label('credentials-save-failed'))
72
+ } finally {
73
+ setBusy(false)
74
+ }
75
+ }
76
+
77
+ const remove = async (name: string): Promise<void> => {
78
+ const drop = props.client.removeCredential
79
+ setRemoving(undefined)
80
+ if (drop === undefined) return
81
+ setBusy(true)
82
+ setFailed('')
83
+ try {
84
+ await drop.call(props.client, name)
85
+ await refresh()
86
+ setNotice('')
87
+ } catch {
88
+ setFailed(label('credentials-save-failed'))
89
+ } finally {
90
+ setBusy(false)
91
+ }
92
+ }
93
+
94
+ return (
95
+ <div className="flex flex-col gap-4">
96
+ <div className="flex items-start justify-between gap-3">
97
+ <div>
98
+ <h4 className="m-0">{label('section-credentials')}</h4>
99
+ {rows.length === 0 ? <p className="mt-1 text-xs text-muted-foreground">{label('credentials-empty')}</p> : null}
100
+ <p className="mt-1 text-xs text-muted-foreground">{label('mcp-reference-hint')}</p>
101
+ </div>
102
+ {writable ? (
103
+ <button type="button" className="h-8 shrink-0 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={() => setDraft(emptyDraft)}>
104
+ {label('credentials-add')}
105
+ </button>
106
+ ) : <span className="shrink-0 text-xs text-muted-foreground">{label('credentials-read-only')}</span>}
107
+ </div>
108
+ <ul className="flex flex-col gap-2">
109
+ {rows.map(row => (
110
+ <li key={row.name} className="flex items-start gap-3 rounded-xl border bg-card px-3 py-2">
111
+ <span className="mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-lg border text-muted-foreground">
112
+ <KeyRound size={14} />
113
+ </span>
114
+ <div className="min-w-0 flex-1">
115
+ <p className="m-0 truncate text-sm font-medium">{row.name}</p>
116
+ <p className="m-0 truncate text-xs text-muted-foreground">{row.description}</p>
117
+ <p className="m-0 mt-1 font-mono text-[11px] text-muted-foreground">{label('credentials-reference').replace('{reference}', `\${credential:${row.name}}`)}</p>
118
+ </div>
119
+ {writable ? (
120
+ <div className="flex shrink-0 gap-1">
121
+ <button type="button" className="h-7 rounded-md px-2 text-xs hover:bg-muted" onClick={() => setDraft({ name: row.name, description: row.description, secret: '', editing: row.name })}>
122
+ {label('credentials-edit')}
123
+ </button>
124
+ <button type="button" className="h-7 rounded-md px-2 text-xs text-destructive hover:bg-muted" onClick={() => setRemoving(row.name)}>
125
+ {label('delete')}
126
+ </button>
127
+ </div>
128
+ ) : null}
129
+ </li>
130
+ ))}
131
+ </ul>
132
+ {notice.length > 0 ? <p className="text-xs text-muted-foreground">{notice}</p> : null}
133
+ {failed.length > 0 ? <p className="text-xs text-destructive">{failed}</p> : null}
134
+ {draft === undefined ? null : (
135
+ <Dialog onClose={() => setDraft(undefined)}>
136
+ <h4 className="m-0">{label(draft.editing === undefined ? 'credentials-new' : 'credentials-edit')}</h4>
137
+ <div className="mt-4 flex flex-col gap-3">
138
+ <Field label={label('credentials-name')} hint={label('credentials-name-hint')} value={draft.name} disabled={draft.editing !== undefined} onChange={name => setDraft({ ...draft, name })} />
139
+ <Field label={label('credentials-description')} hint={label('credentials-desc-hint')} value={draft.description} onChange={description => setDraft({ ...draft, description })} />
140
+ <Field label={label('credentials-secret')} hint={label('credentials-secret-hint')} value={draft.secret} password onChange={secret => setDraft({ ...draft, secret })} />
141
+ {draft.editing === undefined ? null : <p className="m-0 text-xs text-muted-foreground">{label('credentials-replace-hint')}</p>}
142
+ </div>
143
+ <div className="mt-4 flex items-center justify-end gap-2">
144
+ {draft.name.trim().length === 0
145
+ ? <span className="mr-auto text-xs text-muted-foreground">{label('credentials-need-name')}</span>
146
+ : draft.editing === undefined && draft.secret.length === 0
147
+ ? <span className="mr-auto text-xs text-muted-foreground">{label('credentials-need-secret')}</span>
148
+ : null}
149
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={() => setDraft(undefined)}>{label('cancel')}</button>
150
+ <button
151
+ type="button"
152
+ className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground disabled:opacity-50"
153
+ disabled={busy || draft.name.trim().length === 0 || (draft.editing === undefined && draft.secret.length === 0)}
154
+ onClick={() => { void save(draft) }}
155
+ >
156
+ {label('credentials-save')}
157
+ </button>
158
+ </div>
159
+ </Dialog>
160
+ )}
161
+ {removing === undefined ? null : (
162
+ <Dialog width="sm" onClose={() => setRemoving(undefined)}>
163
+ <p className="m-0 text-sm">{label('credentials-delete-confirm')}</p>
164
+ <div className="mt-4 flex justify-end gap-2">
165
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={() => setRemoving(undefined)}>{label('cancel')}</button>
166
+ <button type="button" className="h-8 rounded-lg bg-destructive px-3 text-sm font-semibold text-destructive-foreground" onClick={() => { void remove(removing) }}>{label('delete')}</button>
167
+ </div>
168
+ </Dialog>
169
+ )}
170
+ </div>
171
+ )
172
+ }
173
+
174
+ function Field(props: {
175
+ readonly label: string
176
+ readonly hint: string
177
+ readonly value: string
178
+ readonly password?: boolean
179
+ readonly disabled?: boolean
180
+ readonly onChange: (value: string) => void
181
+ }): ReactNode {
182
+ return (
183
+ <label className="flex flex-col gap-1 text-xs">
184
+ <span className="text-muted-foreground">{props.label}</span>
185
+ <input
186
+ type={props.password === true ? 'password' : 'text'}
187
+ className="h-8 rounded-lg border bg-background px-2 text-sm text-foreground disabled:opacity-60"
188
+ placeholder={props.hint}
189
+ value={props.value}
190
+ disabled={props.disabled === true}
191
+ onChange={event => props.onChange(event.target.value)}
192
+ />
193
+ </label>
194
+ )
195
+ }
@@ -0,0 +1,27 @@
1
+ import { isPanelLocale, type PanelLocale } from '../labels.ts'
2
+
3
+ /** Settings port range. Locked in the product decisions, not a host tunable. */
4
+ export const panelPortBound = { min: 1024, max: 65535 } as const
5
+
6
+ export type PanelPortResult =
7
+ | { readonly ok: true; readonly port: number }
8
+ | { readonly ok: false; readonly key: 'port-invalid' }
9
+
10
+ /**
11
+ * Admit a settings port before any write. A bad value is not sent to Host.
12
+ * @param value - the form value
13
+ */
14
+ export function admitPanelPort(value: unknown): PanelPortResult {
15
+ if (typeof value !== 'number' || !Number.isInteger(value)) return { ok: false, key: 'port-invalid' }
16
+ if (value < panelPortBound.min || value > panelPortBound.max) return { ok: false, key: 'port-invalid' }
17
+ return { ok: true, port: value }
18
+ }
19
+
20
+ /**
21
+ * The one language control. Both fields are the same locale.
22
+ * @param locale - `en` or `zh-CN`
23
+ */
24
+ export function panelLanguageFields(locale: string): { locale: PanelLocale; chatLanguage: PanelLocale } {
25
+ if (!isPanelLocale(locale)) throw new Error(`panel locale is not supported: ${locale}`)
26
+ return { locale, chatLanguage: locale }
27
+ }
@@ -0,0 +1,35 @@
1
+ import { Loader2 } from 'lucide-react'
2
+ import type { ReactNode } from 'react'
3
+
4
+ /** Toggle one id in a remembered pick set. */
5
+ export function toggleId(current: ReadonlySet<string>, id: string): ReadonlySet<string> {
6
+ const next = new Set(current)
7
+ if (next.has(id)) next.delete(id)
8
+ else next.add(id)
9
+ return next
10
+ }
11
+
12
+ /** Home-relative dest shown next to the status caption. */
13
+ export function shortHomePath(dest: string): string {
14
+ const unix = dest.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/)
15
+ if (unix !== null && unix[1] !== undefined) return `~${dest.slice(unix[1].length)}`
16
+ const windows = dest.match(/^[A-Za-z]:\\Users\\[^\\]+/)
17
+ if (windows !== null && windows[0] !== undefined) return `~${dest.slice(windows[0].length).replace(/\\/g, '/')}`
18
+ return dest
19
+ }
20
+
21
+ /** Custom dests must end with a `skills` folder. */
22
+ export function endsWithSkills(raw: string): boolean {
23
+ const text = raw.trim().replace(/\\/g, '/').replace(/\/+$/, '')
24
+ return text === 'skills' || text.endsWith('/skills')
25
+ }
26
+
27
+ /** Caption while a picked dest is being written. */
28
+ export function installingCaption(label: (key: string) => string): ReactNode {
29
+ return (
30
+ <span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
31
+ <Loader2 size={11} className="animate-spin" />
32
+ {label('installing')}
33
+ </span>
34
+ )
35
+ }