@spzxg/cli-ui 0.6.2 → 0.6.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 (131) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +137 -11
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +14 -8
  11. package/src/board/BrowserPane.tsx +160 -74
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +32 -22
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +88 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/panels.ts +33 -0
  23. package/src/board/textLoad.ts +2 -2
  24. package/src/board/workspace.ts +38 -9
  25. package/src/chat/CliChat.tsx +73 -30
  26. package/src/chat/Composer.tsx +41 -41
  27. package/src/chat/RunControls.tsx +99 -0
  28. package/src/chat/configuration.ts +15 -0
  29. package/src/chat/draft.ts +41 -0
  30. package/src/chat/export.ts +2 -2
  31. package/src/components.tsx +13 -0
  32. package/src/core/blocks.ts +2 -2
  33. package/src/core/callbacks.ts +8 -0
  34. package/src/core/client.ts +8 -6
  35. package/src/core/conversation.ts +3 -3
  36. package/src/core/id.ts +15 -0
  37. package/src/core/identity.ts +33 -0
  38. package/src/core/provider.tsx +24 -14
  39. package/src/core/store.ts +83 -26
  40. package/src/core/types.ts +34 -0
  41. package/src/elements.tsx +135 -29
  42. package/src/embed.ts +6 -8
  43. package/src/index.ts +24 -17
  44. package/src/layout/CliSplit.tsx +1 -1
  45. package/src/msglist/CliMsgList.tsx +10 -10
  46. package/src/operator/branchFlow.ts +35 -0
  47. package/src/operator/index.ts +9 -0
  48. package/src/operator/liveStream.ts +1 -0
  49. package/src/operator/node/AgentConnect.tsx +624 -0
  50. package/src/operator/node/AppearancePanel.tsx +35 -0
  51. package/src/operator/node/BranchHistory.tsx +67 -0
  52. package/src/operator/node/BranchNavigator.tsx +36 -0
  53. package/src/operator/node/BrowserPane.tsx +128 -0
  54. package/src/operator/node/BrowserPopup.tsx +22 -0
  55. package/src/operator/node/Chat.tsx +517 -0
  56. package/src/operator/node/CommandPalette.tsx +213 -0
  57. package/src/operator/node/Composer.tsx +287 -0
  58. package/src/operator/node/InsightsView.tsx +831 -0
  59. package/src/operator/node/Layout.tsx +190 -0
  60. package/src/operator/node/Markdown.tsx +61 -0
  61. package/src/operator/node/Modal.tsx +179 -0
  62. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  63. package/src/operator/node/OnboardingWizard.tsx +250 -0
  64. package/src/operator/node/Orchestrator.tsx +634 -0
  65. package/src/operator/node/ProjectLanding.tsx +86 -0
  66. package/src/operator/node/ProjectsColumn.tsx +137 -0
  67. package/src/operator/node/ProviderAuth.tsx +561 -0
  68. package/src/operator/node/RunControls.tsx +2 -0
  69. package/src/operator/node/RunSwitch.tsx +113 -0
  70. package/src/operator/node/Services.tsx +245 -0
  71. package/src/operator/node/SessionRail.tsx +260 -0
  72. package/src/operator/node/SessionView.tsx +288 -0
  73. package/src/operator/node/SettingsView.tsx +690 -0
  74. package/src/operator/node/TaskBoard.tsx +352 -0
  75. package/src/operator/node/TaskChanges.tsx +530 -0
  76. package/src/operator/node/TaskHero.tsx +50 -0
  77. package/src/operator/node/TasksColumn.tsx +200 -0
  78. package/src/operator/node/Terminal.tsx +143 -0
  79. package/src/operator/node/Transcript.tsx +463 -0
  80. package/src/operator/node/ViewFile.tsx +79 -0
  81. package/src/operator/node/Welcome.tsx +101 -0
  82. package/src/operator/node/WorktreePrune.tsx +333 -0
  83. package/src/operator/node/agents.ts +58 -0
  84. package/src/operator/node/api.ts +40 -0
  85. package/src/operator/node/boardBackend.ts +80 -0
  86. package/src/operator/node/boardEvents.ts +22 -0
  87. package/src/operator/node/boardInteraction.ts +15 -0
  88. package/src/operator/node/boardState.ts +57 -0
  89. package/src/operator/node/boardWorkspace.ts +12 -0
  90. package/src/operator/node/branchFlow.ts +1 -0
  91. package/src/operator/node/composerDraft.ts +52 -0
  92. package/src/operator/node/fileLinks.ts +81 -0
  93. package/src/operator/node/format.ts +290 -0
  94. package/src/operator/node/github.tsx +234 -0
  95. package/src/operator/node/icons.tsx +88 -0
  96. package/src/operator/node/liveStream.ts +270 -0
  97. package/src/operator/node/modals.tsx +720 -0
  98. package/src/operator/node/navHistory.ts +70 -0
  99. package/src/operator/node/persist.ts +26 -0
  100. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  101. package/src/operator/node/protocol/agents/types.ts +394 -0
  102. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  103. package/src/operator/node/protocol/authFailure.ts +66 -0
  104. package/src/operator/node/protocol/customModels.ts +23 -0
  105. package/src/operator/node/protocol/events.ts +13 -0
  106. package/src/operator/node/protocol/features.ts +70 -0
  107. package/src/operator/node/protocol/internalUsage.ts +1 -0
  108. package/src/operator/node/protocol/promptLimits.ts +52 -0
  109. package/src/operator/node/protocol/storageRetention.ts +13 -0
  110. package/src/operator/node/protocol/types.ts +332 -0
  111. package/src/operator/node/protocol/usageLimit.ts +72 -0
  112. package/src/operator/node/shared.tsx +89 -0
  113. package/src/operator/node/transcriptModel.ts +129 -0
  114. package/src/operator/node/types.ts +296 -0
  115. package/src/operator/node/useGlobalEvents.ts +97 -0
  116. package/src/operator/node/useOrchestrator.ts +696 -0
  117. package/src/operator/node/usePrefs.ts +114 -0
  118. package/src/operator/node/useRecaps.ts +91 -0
  119. package/src/operator/node/useTaskStream.ts +279 -0
  120. package/src/operator.base.css +1798 -0
  121. package/src/operator.css +186 -16
  122. package/src/styles.css +20 -0
  123. package/src/ui/Markdown.tsx +7 -2
  124. package/src/ui/Popover.tsx +77 -0
  125. package/src/ui/TextBlock.tsx +3 -3
  126. package/src/ui/Transcript.tsx +10 -8
  127. package/src/ui/fileLinks.ts +6 -0
  128. package/src/ui/format.ts +1 -1
  129. package/src/ui/shared.tsx +1 -1
  130. package/src/ui/transcriptModel.ts +2 -2
  131. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -0,0 +1,690 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Icon } from "./icons";
5
+ import { DEFAULT_SETTINGS, modelOptions, reasoningOptions, permissionOptions, type Settings, type AgentsBundle } from "./types";
6
+ import { savedCustomModels, validModelId, withCustomModels } from "./protocol/customModels";
7
+ import { capsFor, agentLabel, agentStateHint } from "./agents";
8
+ import { GitHubSettings } from "./github";
9
+ import { WorktreePrune } from "./WorktreePrune";
10
+ import { AgentConnect } from "./AgentConnect";
11
+ import { LoadNote, ErrNote } from "./shared";
12
+ import { jget, jsend } from "./api";
13
+ import type { AgentInfoT, AgentsResponseT } from "./types";
14
+
15
+ // Account / session panel. Shows who's signed in to this instance and a Logout
16
+ // control — but only when an origin provider is actually gating the box (first-
17
+ // party control-plane session or Cloudflare Access). In open local dev there's
18
+ // no session to end, so the panel says so and hides the button. The redirect
19
+ // target is provider-specific and decided server-side (see /api/auth/logout).
20
+ function AccountSection() {
21
+ const [state, setState] = useState<
22
+ { provider: string; signedIn: boolean; email: string | null } | null
23
+ >(null);
24
+ const [busy, setBusy] = useState(false);
25
+
26
+ useEffect(() => {
27
+ let cancelled = false;
28
+ fetch("/api/auth/whoami")
29
+ .then((r) => r.json())
30
+ .then((d) => {
31
+ if (!cancelled) setState(d);
32
+ })
33
+ .catch(() => {
34
+ if (!cancelled) setState({ provider: "none", signedIn: false, email: null });
35
+ });
36
+ return () => {
37
+ cancelled = true;
38
+ };
39
+ }, []);
40
+
41
+ async function logout() {
42
+ setBusy(true);
43
+ try {
44
+ const res = await fetch("/api/auth/logout", { method: "POST" });
45
+ const data = await res.json().catch(() => ({}));
46
+ // Top-level navigation so the CF logout (or CP login) loads as a real page.
47
+ window.location.href = data?.redirect || "/";
48
+ } catch {
49
+ setBusy(false);
50
+ }
51
+ }
52
+
53
+ return (
54
+ <div className="field">
55
+ <div className="lab">{Icon.lock()} 登录状态</div>
56
+ {state == null ? (
57
+ <LoadNote style={{ padding: 0 }}>正在检查你的会话…</LoadNote>
58
+ ) : state.signedIn ? (
59
+ <>
60
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 12 }}>
61
+ {state.email ? <strong>{state.email}</strong> : "已登录"}
62
+ </div>
63
+ <button
64
+ className="btn btn-line"
65
+ onClick={logout}
66
+ disabled={busy}
67
+ style={{ alignSelf: "flex-start" }}
68
+ >
69
+ {Icon.external()} {busy ? "正在退出…" : "退出登录"}
70
+ </button>
71
+ <div className="hlp" style={{ marginTop: 10 }}>
72
+ {state.provider === "cf-access"
73
+ ? "这会结束你在本实例上的 Cloudflare Access 会话。"
74
+ : "这会结束你的会话并返回登录页。"}
75
+ </div>
76
+ </>
77
+ ) : (
78
+ <div className="hlp" style={{ marginTop: 0 }}>
79
+ 本实例没有启用登录(本地 / 开放模式)—— 没有可结束的会话。
80
+ </div>
81
+ )}
82
+ </div>
83
+ );
84
+ }
85
+
86
+ // Settings → API access. The token here lets a script call this instance from a
87
+ // Host outside the allowlist by sending `Authorization: Bearer <token>`.
88
+ //
89
+ // Saving takes effect immediately — no restart. The gate is Next middleware,
90
+ // which runs in the edge runtime and cannot open SQLite, but it does read
91
+ // process.env and shares this process, so lib/apiToken.ts persists the secret to
92
+ // a 0600 file (not the settings table, which /api/settings hands to the browser
93
+ // wholesale) and mirrors it into env on write and on boot.
94
+ //
95
+ // The server never reads a token back: it reports enabled / source / last-6
96
+ // only. A token generated here is shown once, in this browser, and then only
97
+ // its tail is recoverable.
98
+ type TokenState = { enabled: boolean; source: "none" | "file" | "env"; hint: string | null; token?: string | null };
99
+
100
+ function ApiAccessSection() {
101
+ const [state, setState] = useState<TokenState | null>(null);
102
+ const [minted, setMinted] = useState("");
103
+ const [busy, setBusy] = useState(false);
104
+ const [err, setErr] = useState<string | null>(null);
105
+ const [copied, setCopied] = useState("");
106
+ const [confirmOff, setConfirmOff] = useState(false);
107
+ // The live token in full. The server only reveals it to a loopback browser,
108
+ // so off-machine viewers keep working with the hint alone.
109
+ const [revealed, setRevealed] = useState("");
110
+
111
+ const load = () =>
112
+ jget<TokenState>("/api/settings/api-token")
113
+ .then(setState)
114
+ .catch(() => setState({ enabled: false, source: "none", hint: null }));
115
+ useEffect(() => { load(); }, []);
116
+
117
+ const reveal = async () => {
118
+ setErr(null);
119
+ try {
120
+ const s = await jget<TokenState>("/api/settings/api-token?reveal=1");
121
+ if (!s.token) throw new Error("只有在这台机器上打开的界面才能查看完整令牌");
122
+ setRevealed(s.token);
123
+ return s.token;
124
+ } catch (e) {
125
+ setErr(e instanceof Error ? e.message : String(e));
126
+ return "";
127
+ }
128
+ };
129
+
130
+ // 32 bytes of CSPRNG entropy, hex — the same shape as `openssl rand -hex 32`.
131
+ const mint = () => {
132
+ const bytes = new Uint8Array(32);
133
+ crypto.getRandomValues(bytes);
134
+ setMinted(Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""));
135
+ setErr(null);
136
+ setCopied("");
137
+ };
138
+ const copy = async (what: string, text: string) => {
139
+ try {
140
+ await navigator.clipboard.writeText(text);
141
+ setCopied(what);
142
+ setTimeout(() => setCopied(""), 1500);
143
+ } catch { /* clipboard blocked */ }
144
+ };
145
+ const save = async () => {
146
+ if (!minted) return;
147
+ setBusy(true);
148
+ setErr(null);
149
+ try {
150
+ setState(await jsend<TokenState>("/api/settings/api-token", "POST", { token: minted }));
151
+ setRevealed(minted);
152
+ } catch (e) {
153
+ setErr(e instanceof Error ? e.message : String(e));
154
+ } finally {
155
+ setBusy(false);
156
+ }
157
+ };
158
+ const disable = async () => {
159
+ setBusy(true);
160
+ setErr(null);
161
+ try {
162
+ setState(await jsend<TokenState>("/api/settings/api-token", "DELETE"));
163
+ setMinted("");
164
+ setRevealed("");
165
+ setConfirmOff(false);
166
+ } catch (e) {
167
+ setErr(e instanceof Error ? e.message : String(e));
168
+ } finally {
169
+ setBusy(false);
170
+ }
171
+ };
172
+
173
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
174
+ const envManaged = state?.source === "env";
175
+ // The live token is in hand right after minting, or once a local browser has
176
+ // asked the server to reveal it; otherwise show its tail as a placeholder.
177
+ const live = minted || revealed;
178
+ const forCurl = live || (state?.hint ? `…${state.hint}` : "<你的令牌>");
179
+
180
+ return (
181
+ <>
182
+ <div className="field">
183
+ <div className="lab">{Icon.lock()} 接口访问令牌</div>
184
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 12 }}>
185
+ 默认情况下,只有本机(回环地址)能调用这个实例的接口。设置一个令牌后,脚本就能从别的机器带
186
+ <code>Authorization: Bearer …</code> 头调用。<strong>保存后立即生效,无需重启。</strong>
187
+ 终端 <code>/pty</code> 不受此令牌影响,始终只对本机开放。
188
+ </div>
189
+ {state === null ? (
190
+ <LoadNote style={{ padding: 0 }}>正在读取当前状态…</LoadNote>
191
+ ) : state.enabled ? (
192
+ <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
193
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 7, color: "var(--ink)" }}>
194
+ <span className="wiz-ok">{Icon.check()}</span>
195
+ {/* Kept in one span: loose text and <code> beside the icon would each
196
+ become a flex item and break apart once the line wraps. */}
197
+ <span>已启用{state.hint ? <> —— 当前令牌以 <code>…{state.hint}</code> 结尾</> : ""}</span>
198
+ </span>
199
+ {envManaged ? (
200
+ <span className="opt">· 来自环境变量,界面不可修改</span>
201
+ ) : confirmOff ? (
202
+ <>
203
+ <button className="btn-danger on" disabled={busy} onClick={disable}>{Icon.x()} 确认停用</button>
204
+ <button className="btn btn-ghost btn-sm" onClick={() => setConfirmOff(false)}>取消</button>
205
+ </>
206
+ ) : (
207
+ <button className="btn btn-line btn-sm" onClick={() => setConfirmOff(true)}>{Icon.x()} 停用</button>
208
+ )}
209
+ </div>
210
+ ) : (
211
+ <div style={{ display: "inline-flex", alignItems: "center", gap: 7, color: "var(--ink-2)" }}>
212
+ {Icon.lock()} 未启用 —— 只有本机可以调用接口
213
+ </div>
214
+ )}
215
+ {err && <ErrNote style={{ marginTop: 10 }}>{err}</ErrNote>}
216
+ </div>
217
+
218
+ {!envManaged && (
219
+ <div className="field">
220
+ <div className="lab">{state?.enabled ? "更换令牌" : "生成并启用"}</div>
221
+ <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
222
+ <button className="btn btn-line" onClick={mint}>{Icon.spark()} {minted ? "重新生成" : "生成令牌"}</button>
223
+ {minted && (
224
+ <>
225
+ <button className="btn btn-line" onClick={() => copy("token", minted)}>
226
+ {copied === "token" ? Icon.check() : Icon.copy()} {copied === "token" ? "已复制" : "复制"}
227
+ </button>
228
+ <button className="btn btn-accent" disabled={busy} onClick={save}>
229
+ {Icon.check()} {busy ? "保存中…" : state?.enabled ? "保存并替换" : "保存并启用"}
230
+ </button>
231
+ </>
232
+ )}
233
+ </div>
234
+ {minted && (
235
+ <div className="ctx-mono" style={{ marginTop: 10, padding: "9px 12px", background: "var(--raise)", border: "1px solid var(--line-strong)", borderRadius: "var(--r)", fontSize: 12, wordBreak: "break-all" }}>
236
+ {minted}
237
+ </div>
238
+ )}
239
+ <div className="hlp">
240
+ 在浏览器本地生成,保存前不会离开这台机器。<strong>只显示这一次</strong> —— 保存后服务端只会告诉你它的后 6 位,请先复制留存。
241
+ </div>
242
+ </div>
243
+ )}
244
+
245
+ <div className="field" style={{ marginBottom: 0 }}>
246
+ <div className="lab">调用方式</div>
247
+ <div className="ctx-mono" style={{ padding: "11px 13px", background: "var(--raise)", border: "1px solid var(--line-strong)", borderRadius: "var(--r)", fontSize: 12, whiteSpace: "pre-wrap", wordBreak: "break-all", lineHeight: 1.7 }}>
248
+ {`curl -s ${origin}/api/projects \\\n -H 'Authorization: Bearer ${forCurl}'`}
249
+ </div>
250
+ <div style={{ display: "flex", gap: 8, marginTop: 8 }}>
251
+ <button className="btn btn-line btn-sm"
252
+ onClick={async () => {
253
+ // Copy the runnable command: fetch the real token first when this
254
+ // browser doesn't already have it.
255
+ const token = live || (state?.enabled ? await reveal() : "");
256
+ if (state?.enabled && !token) return;
257
+ copy("cmd", `curl -s ${origin}/api/projects -H 'Authorization: Bearer ${token || "<你的令牌>"}'`);
258
+ }}>
259
+ {copied === "cmd" ? Icon.check() : Icon.copy()} {copied === "cmd" ? "已复制" : "复制命令"}
260
+ </button>
261
+ {state?.enabled && !live && (
262
+ <button className="btn btn-ghost btn-sm" onClick={reveal}>显示完整令牌</button>
263
+ )}
264
+ </div>
265
+ <div className="hlp">
266
+ 从别的机器调用,还需要让服务监听局域网网卡 —— 启动时加上 <code>ORCH_HOSTNAME=0.0.0.0</code>(这一项是网络绑定,必须重启才能改变)。
267
+ </div>
268
+ <div className="hlp" style={{ color: "var(--amber)" }}>
269
+ 令牌在 http 下是明文传输的。跨越不可信网络时,请在实例前面加一层 TLS。
270
+ </div>
271
+ </div>
272
+ </>
273
+ );
274
+ }
275
+
276
+ // The "Agents" section: connect coding agents beyond the required first-run
277
+ // Claude one. Claude appears here as already-connected; Codex (and any future
278
+ // agent) gets a "connect another agent" card driven by AgentConnect against the
279
+ // generic /api/agents/[id]/* routes. Reads the same GET /api/agents the task
280
+ // pickers gate on, so connecting here immediately un-grays the agent there.
281
+ function AgentsSection({ defaultAgent, onChanged }: { defaultAgent: string; onChanged?: () => void }) {
282
+ const [agents, setAgents] = useState<AgentInfoT[] | null>(null);
283
+ const [def, setDef] = useState<string>(defaultAgent);
284
+
285
+ const load = () =>
286
+ jget<AgentsResponseT>("/api/agents")
287
+ .then((r) => { setAgents(r.agents); setDef(r.default); })
288
+ .catch(() => setAgents([]));
289
+ useEffect(() => { load(); }, []);
290
+
291
+ if (agents == null) return <LoadNote style={{ padding: 0 }}>正在加载智能体…</LoadNote>;
292
+
293
+ return (
294
+ <div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
295
+ <div className="hlp" style={{ marginTop: 0 }}>
296
+ 每个任务都由一个编程智能体执行。连接一次智能体的订阅账号(或 API 密钥),新任务就能选用它。{def === "claude" ? "Claude 是默认智能体,还负责应用自身的辅助任务(摘要、回顾),建议保持连接。" : ""}
297
+ </div>
298
+ {agents.map((a) => (
299
+ <div key={a.id} className="field" style={{ marginBottom: 0 }}>
300
+ <div className="lab" style={{ display: "flex", alignItems: "center", gap: 8 }}>
301
+ {Icon.spark()} {a.label}
302
+ {a.id === def && <span className="opt">— 默认</span>}
303
+ {a.connected && (a.authBroken
304
+ // Connected on record but its login died — a green check here would
305
+ // contradict the card below it (and the titlebar banner).
306
+ ? <span className="wiz-warn" style={{ marginLeft: "auto" }} title="登录已失效">{Icon.bolt()}</span>
307
+ : <span className="wiz-ok" style={{ marginLeft: "auto" }}>{Icon.check()}</span>)}
308
+ </div>
309
+ <AgentConnect agent={a} compact onConnected={() => { load(); onChanged?.(); }} />
310
+ </div>
311
+ ))}
312
+ </div>
313
+ );
314
+ }
315
+
316
+ // The EFFECTIVE utility agent, resolved connected-first by the server
317
+ // (lib/agents/oneshots.ts). The buttons above show what's *configured*; this
318
+ // line shows what will actually run — they diverge whenever the configured
319
+ // agent isn't connected, and silently picking a different agent would be a
320
+ // worse surprise than saying so. When nothing is connected at all, internal
321
+ // jobs can't run, so this says that instead of naming a stand-in.
322
+ function UtilityEffective({ agents }: { agents: AgentsBundle }) {
323
+ const u = agents.utility;
324
+ if (!u) return null;
325
+ if (!u.id)
326
+ return (
327
+ <div className="hlp" style={{ marginTop: 8 }}>
328
+ {Icon.bolt()} 尚未连接任何智能体 —— 项目回顾与上下文刷新已暂停。请在「设置 → 智能体」中连接一个。
329
+ </div>
330
+ );
331
+ const label = agentLabel(agents, u.id);
332
+ return (
333
+ <div className="hlp" style={{ marginTop: 8 }}>
334
+ 当前运行于 <strong>{label}</strong>
335
+ {u.fallback && <span className="opt">(回退 —— {agentLabel(agents, u.configured)} 未连接)</span>}
336
+ </div>
337
+ );
338
+ }
339
+
340
+ // The settings surface is a two-pane view that replaces the work area: a category
341
+ // nav (left) + the active section's content (right). Sections are data-driven so
342
+ // growing settings is adding an entry here + a branch in renderSection — no layout
343
+ // work. Today there's one section; appearance/models/integrations slot in later.
344
+ const SETTINGS_SECTIONS: { id: string; label: string; icon: () => React.ReactNode }[] = [
345
+ { id: "general", label: "通用", icon: Icon.gear },
346
+ { id: "background", label: "后台任务", icon: Icon.clock },
347
+ { id: "run", label: "运行默认值", icon: Icon.spark },
348
+ { id: "agents", label: "智能体", icon: Icon.bolt },
349
+ { id: "storage", label: "存储", icon: Icon.archive },
350
+ { id: "github", label: "GitHub", icon: Icon.github },
351
+ { id: "account", label: "账号", icon: Icon.lock },
352
+ { id: "api", label: "接口访问", icon: Icon.copy },
353
+ { id: "setup", label: "初始设置", icon: Icon.bolt },
354
+ ];
355
+
356
+ export function SettingsView({ settings, setSetting, appDefaults, setAppDefault, agents, onAgentsRefresh, onReset, onRerunSetup, onClose, initialSection }: {
357
+ settings: Settings;
358
+ setSetting: <K extends keyof Settings>(k: K, v: Settings[K]) => void;
359
+ appDefaults: Record<string, string>;
360
+ setAppDefault: (key: string, value: string | null) => Promise<void>;
361
+ agents: AgentsBundle;
362
+ onAgentsRefresh?: () => void;
363
+ onReset: () => void|Promise<void>;
364
+ onRerunSetup: () => void;
365
+ onClose: () => void;
366
+ initialSection?: string;
367
+ }) {
368
+ const [resetting,setResetting]=useState(false);
369
+ const [resetError,setResetError]=useState<string|null>(null);
370
+ const reset=async()=>{if(resetting)return;setResetting(true);setResetError(null);try{await onReset();}catch(e){setResetError(e instanceof Error?e.message:String(e));}finally{setResetting(false);}};
371
+ const [section, setSection] = useState<string>(
372
+ initialSection && SETTINGS_SECTIONS.some((s) => s.id === initialSection) ? initialSection : SETTINGS_SECTIONS[0].id
373
+ );
374
+ // Which agent's run defaults are being edited (defaults are per-agent, keyed
375
+ // "default_reasoning:<agent>"). Falls back to the app default agent.
376
+ const appDefaultAgent = appDefaults.default_agent || agents.default;
377
+ const [editAgent, setEditAgent] = useState(appDefaultAgent);
378
+ const caps = capsFor(agents, editAgent);
379
+ // Agent-scoped default, with legacy un-suffixed keys as a fallback (mirrors the
380
+ // driver's resolution) so pre-existing settings still show as selected.
381
+ const modelVal = appDefaults[`default_model:${editAgent}`] ?? appDefaults.default_model ?? null;
382
+ const [customModel, setCustomModel] = useState("");
383
+ const [modelSaving, setModelSaving] = useState(false);
384
+ const [modelError, setModelError] = useState<string | null>(null);
385
+ const models = withCustomModels(caps?.models ?? [], [...new Set([
386
+ ...savedCustomModels(appDefaults[`custom_models:${editAgent}`]), ...(modelVal ? [modelVal] : []),
387
+ ])]);
388
+ useEffect(() => { setCustomModel(""); setModelError(null); }, [editAgent]);
389
+ const addModel = async () => {
390
+ if (!validModelId(customModel)) { setModelError("请输入有效的模型名称"); return; }
391
+ setModelSaving(true);
392
+ setModelError(null);
393
+ try {
394
+ await setAppDefault(`default_model:${editAgent}`, customModel.trim());
395
+ setCustomModel("");
396
+ } catch (error) {
397
+ setModelError(error instanceof Error ? error.message : "保存失败");
398
+ } finally { setModelSaving(false); }
399
+ };
400
+ const reasoningVal = appDefaults[`default_reasoning:${editAgent}`] ?? appDefaults.default_reasoning ?? null;
401
+ const permissionVal = appDefaults[`default_permission_mode:${editAgent}`] ?? appDefaults.default_permission_mode ?? null;
402
+ const multiAgent = agents.agents.length > 1;
403
+ const backgroundJobs = appDefaults.background_jobs !== "off";
404
+ const agentFailover = appDefaults.agent_failover_enabled !== "off";
405
+ const recapMode = appDefaults.recap_mode === "on_open" || appDefaults.recap_mode === "off"
406
+ ? appDefaults.recap_mode
407
+ : "automatic";
408
+ const [jobUsage, setJobUsage] = useState<Record<string, { runs: number; cost_usd: number }> | null>(null);
409
+ useEffect(() => {
410
+ if (section !== "background" || jobUsage !== null) return;
411
+ jget<{ jobs: { job: string; runs: number; cost_usd: number }[] }>("/api/settings/background-jobs")
412
+ .then((data) => setJobUsage(Object.fromEntries(data.jobs.map((j) => [j.job, { runs: j.runs, cost_usd: j.cost_usd }]))))
413
+ .catch(() => setJobUsage({}));
414
+ }, [jobUsage, section]);
415
+ const recapUsage = jobUsage?.summarizeProjectRecap ?? { runs: 0, cost_usd: 0 };
416
+ const utilityUsage = [jobUsage?.summarizeProjectRecap, jobUsage?.draftProjectContext]
417
+ .filter((u): u is { runs: number; cost_usd: number } => !!u)
418
+ .reduce((sum, u) => ({ runs: sum.runs + u.runs, cost_usd: sum.cost_usd + u.cost_usd }), { runs: 0, cost_usd: 0 });
419
+ const usageLine = (label: string, usage = recapUsage) =>
420
+ `${label} · 最近 30 天运行 ${usage.runs.toLocaleString()} 次 · 约 $${usage.cost_usd.toFixed(2)}`;
421
+ // Any server-backed run default set (agent-scoped, legacy, or default_agent)
422
+ // means we're off the built-in defaults.
423
+ const hasRunDefault = Object.keys(appDefaults).some((k) => k.startsWith("default_") || k === "utility_agent");
424
+ const hasBackgroundDefault = !backgroundJobs || recapMode !== "automatic";
425
+ const hasFailoverSetting = appDefaults.agent_failover_enabled === "off";
426
+ const isDefault = settings.clearThresholdPct === DEFAULT_SETTINGS.clearThresholdPct
427
+ && settings.clearThresholdTokens === DEFAULT_SETTINGS.clearThresholdTokens
428
+ && !hasRunDefault
429
+ && !hasBackgroundDefault
430
+ && !hasFailoverSetting;
431
+ // Clamp on commit so a half-typed value never persists out of range.
432
+ const clampPct = (n: number) => Math.min(100, Math.max(1, Math.round(n)));
433
+ const clampTokens = (n: number) => Math.max(1000, Math.round(n));
434
+ const active = SETTINGS_SECTIONS.find((s) => s.id === section) ?? SETTINGS_SECTIONS[0];
435
+ return (
436
+ <>
437
+ <div className="col settings-nav">
438
+ {resetError && <div role="alert" className="comp-error">{resetError}</div>}
439
+ <div className="settings-nav-h">设置</div>
440
+ <div className="settings-nav-list">
441
+ {SETTINGS_SECTIONS.map((s) => (
442
+ <button key={s.id} className={`nav-item${section === s.id ? " active" : ""}`} onClick={() => setSection(s.id)}>
443
+ {s.icon()} {s.label}
444
+ </button>
445
+ ))}
446
+ </div>
447
+ <div className="settings-nav-foot">{section === "background" ? "智能体辅助任务 · 保存在此工作区" : section === "run" ? "运行默认值 · 保存在此工作区" : section === "agents" ? "编程智能体登录 · 保存在此工作区" : section === "storage" ? "磁盘清理 · 作用于此工作区" : section === "github" ? "GitHub 连接 · 保存在此工作区" : section === "account" ? "你在本实例上的登录" : section === "api" ? "接口令牌 · 保存后立即生效" : section === "setup" ? "首次运行设置 · 保存在此工作区" : "应用级偏好 · 保存在此浏览器"}</div>
448
+ </div>
449
+ <div className="col col-session">
450
+ <div className="settings-head">
451
+ <div className="settings-title">{active.label}</div>
452
+ <span className="spacer" />
453
+ <button className="btn btn-ghost btn-sm" onClick={()=>void reset()} disabled={isDefault||resetting} title="把所有设置恢复为默认值">{Icon.restore()} {resetting?"正在恢复…":"恢复默认"}</button>
454
+ <button className="btn btn-line btn-sm" onClick={onClose}>{Icon.chevRight({ style: { transform: "rotate(180deg)" } })} 返回工作区</button>
455
+ </div>
456
+ <div className="scroll">
457
+ <div className="settings-body">
458
+ {section === "general" && (
459
+ <div className="field">
460
+ <div className="lab">{Icon.clear()} /clear 提示阈值</div>
461
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 10 }}>
462
+ 当会话的上下文窗口超过其中任一阈值时,应用会提示你运行 <code>/clear</code> 重新开始。以先达到的那个为准。
463
+ </div>
464
+ <div style={{ display: "flex", gap: 14, maxWidth: 420 }}>
465
+ <div className="field" style={{ flex: 1, marginBottom: 0 }}>
466
+ <div className="lab">窗口占比 <span className="opt">— %</span></div>
467
+ <input
468
+ type="number" min={1} max={100} value={settings.clearThresholdPct}
469
+ onChange={(e) => setSetting("clearThresholdPct", Number(e.target.value) || 0)}
470
+ onBlur={(e) => setSetting("clearThresholdPct", clampPct(Number(e.target.value) || DEFAULT_SETTINGS.clearThresholdPct))}
471
+ />
472
+ </div>
473
+ <div className="field" style={{ flex: 1, marginBottom: 0 }}>
474
+ <div className="lab">绝对 token 数 <span className="opt">— 个</span></div>
475
+ <input
476
+ type="number" min={1000} step={1000} value={settings.clearThresholdTokens}
477
+ onChange={(e) => setSetting("clearThresholdTokens", Number(e.target.value) || 0)}
478
+ onBlur={(e) => setSetting("clearThresholdTokens", clampTokens(Number(e.target.value) || DEFAULT_SETTINGS.clearThresholdTokens))}
479
+ />
480
+ </div>
481
+ </div>
482
+ <div className="hlp" style={{ marginTop: 8 }}>
483
+ 默认值:{DEFAULT_SETTINGS.clearThresholdPct}% 或 {DEFAULT_SETTINGS.clearThresholdTokens.toLocaleString()} tokens。
484
+ </div>
485
+ </div>
486
+ )}
487
+ {section === "background" && (
488
+ <>
489
+ <div className="field">
490
+ <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
491
+ <div style={{ flex: 1 }}>
492
+ <div className="lab">{Icon.bolt()} 模型故障自动切换</div>
493
+ <div className="hlp" style={{ marginTop: 4 }}>
494
+ 当前模型因限流、额度或服务不可用失败时,自动尝试另一个智能体一次。
495
+ </div>
496
+ </div>
497
+ <button
498
+ role="switch"
499
+ aria-label="模型故障自动切换"
500
+ aria-checked={agentFailover}
501
+ className={`in-switch${agentFailover ? " on" : ""}`}
502
+ onClick={() => setAppDefault("agent_failover_enabled", agentFailover ? "off" : null)}
503
+ ><span /></button>
504
+ </div>
505
+ </div>
506
+ <div className="field">
507
+ <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
508
+ <div style={{ flex: 1 }}>
509
+ <div className="lab">{Icon.bolt()} 允许 Operator 用你的智能体执行后台工作</div>
510
+ <div className="hlp" style={{ marginTop: 4 }}>
511
+ 关闭后将停止无人值守的智能体工作。你主动触发的操作 —— 例如 <code>/clear</code>、「用 AI 刷新」或手动刷新回顾 —— 仍会照常运行。
512
+ </div>
513
+ </div>
514
+ <button
515
+ role="switch"
516
+ aria-label="允许 Operator 用你的智能体执行后台工作"
517
+ aria-checked={backgroundJobs}
518
+ className={`in-switch${backgroundJobs ? " on" : ""}`}
519
+ onClick={() => setAppDefault("background_jobs", backgroundJobs ? "off" : null)}
520
+ ><span /></button>
521
+ </div>
522
+ <div className="hlp" style={{ marginTop: 10 }}>{jobUsage === null ? "正在加载最近 30 天用量…" : usageLine("项目回顾活动")}</div>
523
+ </div>
524
+
525
+ <div className="field">
526
+ <div className="lab">{Icon.clock()} 项目回顾</div>
527
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 10 }}>
528
+ 选择 Operator 何时生成新的「上次进度」回顾。任何模式下都可以手动刷新。
529
+ </div>
530
+ <div className="seg" style={{ flexWrap: "wrap", maxWidth: 620 }}>
531
+ {([
532
+ ["automatic", "自动"],
533
+ ["on_open", "仅在我打开项目时"],
534
+ ["off", "关闭"],
535
+ ] as const).map(([value, label]) => (
536
+ <button key={value} className={recapMode === value ? "on" : ""} onClick={() => setAppDefault("recap_mode", value === "automatic" ? null : value)}>{label}</button>
537
+ ))}
538
+ </div>
539
+ <div className="hlp" style={{ marginTop: 10 }}>{jobUsage === null ? "正在加载最近 30 天用量…" : usageLine("项目回顾")}</div>
540
+ </div>
541
+
542
+ <div className="field">
543
+ <div className="lab">{Icon.spark()} 辅助智能体</div>
544
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 10 }}>
545
+ 把 Operator 的辅助工作放到更便宜或第二个账号上 —— 例如用 Codex 跑回顾,把 Claude 的额度留给主要任务。
546
+ </div>
547
+ <div className="seg" style={{ flexWrap: "wrap", maxWidth: 520 }}>
548
+ {agents.agents.map((a) => (
549
+ <button
550
+ key={a.id}
551
+ className={(appDefaults.utility_agent || agents.utility?.configured || appDefaultAgent) === a.id ? "on" : ""}
552
+ title={agentStateHint(a) ? `${a.label} ${agentStateHint(a) === "未安装" ? "的命令行工具未安装在服务器上" : "尚未连接"}` : `用 ${a.label} 运行辅助任务`}
553
+ onClick={() => setAppDefault("utility_agent", a.id)}
554
+ >
555
+ {a.label}{agentStateHint(a) && <span className="opt"> · {agentStateHint(a)}</span>}
556
+ </button>
557
+ ))}
558
+ </div>
559
+ <UtilityEffective agents={agents} />
560
+ <div className="hlp" style={{ marginTop: 10 }}>{jobUsage === null ? "正在加载最近 30 天用量…" : usageLine("辅助智能体任务", utilityUsage)}</div>
561
+ </div>
562
+ </>
563
+ )}
564
+ {section === "run" && (
565
+ <>
566
+ {multiAgent && (
567
+ <div className="field">
568
+ <div className="lab">{Icon.bolt()} 默认智能体</div>
569
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 10 }}>
570
+ 当项目未设置自己的默认值时,新任务使用的智能体。任务创建后其智能体即固定。
571
+ </div>
572
+ <div className="seg" style={{ flexWrap: "wrap", maxWidth: 520 }}>
573
+ {agents.agents.map((a) => (
574
+ <button
575
+ key={a.id}
576
+ className={appDefaultAgent === a.id ? "on" : ""}
577
+ title={agentStateHint(a) ? `${a.label} ${agentStateHint(a) === "未安装" ? "的命令行工具未安装在服务器上" : "尚未连接"}` : `新任务默认使用 ${a.label}`}
578
+ onClick={() => setAppDefault("default_agent", a.id)}
579
+ >
580
+ {a.label}{agentStateHint(a) && <span className="opt"> · {agentStateHint(a)}</span>}
581
+ </button>
582
+ ))}
583
+ </div>
584
+ </div>
585
+ )}
586
+ {multiAgent && (
587
+ <div className="field">
588
+ <div className="lab">要编辑哪个智能体的运行默认值</div>
589
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 10 }}>
590
+ 每个智能体都有各自的推理强度与权限默认值 —— 选择要编辑的那个。
591
+ </div>
592
+ <div className="seg" style={{ flexWrap: "wrap", maxWidth: 520 }}>
593
+ {agents.agents.map((a) => (
594
+ <button key={a.id} className={editAgent === a.id ? "on" : ""} onClick={() => setEditAgent(a.id)}>{a.label}</button>
595
+ ))}
596
+ </div>
597
+ </div>
598
+ )}
599
+ <div className="field">
600
+ <div className="lab">{Icon.spark()} 默认模型</div>
601
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 10 }}>
602
+ 新任务、以及模型选择器保持<strong>默认</strong>的任务,实际使用的模型。留在「默认」则交给
603
+ {agentLabel(agents, editAgent)} 自己决定。任务级设置始终优先。
604
+ </div>
605
+ {/* A dropdown, not the button row the other two use: Claude alone
606
+ offers a dozen-plus pinned versions, which no seg can hold. */}
607
+ <select
608
+ className="ctx-mono"
609
+ style={{ maxWidth: 420 }}
610
+ value={modelVal ?? ""}
611
+ aria-label="默认模型"
612
+ onChange={(e) => setAppDefault(`default_model:${editAgent}`, e.target.value || null)}
613
+ >
614
+ {modelOptions(caps ? { ...caps, models } : undefined).map((m) => (
615
+ <option key={m.value ?? "default"} value={m.value ?? ""}>
616
+ {m.group ? `${m.group} · ` : ""}{m.label}{m.sub ? ` — ${m.sub}` : ""}
617
+ </option>
618
+ ))}
619
+ </select>
620
+ {caps?.supportsCustomModels && (
621
+ <form onSubmit={(event) => { event.preventDefault(); void addModel(); }} style={{ marginTop: 12 }}>
622
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
623
+ <input className="ctx-mono" aria-label="添加自定义模型" placeholder="输入模型名称 / ID" value={customModel} maxLength={2048} disabled={modelSaving} onChange={(event) => setCustomModel(event.target.value)} />
624
+ <button className="btn btn-accent" type="submit" disabled={modelSaving || !customModel.trim()}>{modelSaving ? "保存中…" : "添加并设为默认"}</button>
625
+ </div>
626
+ <div className="hlp">填写当前服务支持的模型名称,保存后可在模型列表中重复选择。</div>
627
+ {modelError && <div className="hlp" style={{ color: "var(--red)" }}>{modelError}</div>}
628
+ </form>
629
+ )}
630
+ </div>
631
+ <div className="field">
632
+ <div className="lab">{Icon.spark()} 默认推理强度</div>
633
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 10 }}>
634
+ 当任务自身的选择器设为<strong>默认</strong>时所使用的思考强度。任务级设置始终优先。
635
+ </div>
636
+ <div className="seg" style={{ flexWrap: "wrap", maxWidth: 520 }}>
637
+ {reasoningOptions(caps).map((r) => (
638
+ <button
639
+ key={r.label}
640
+ className={reasoningVal === r.value ? "on" : ""}
641
+ title={r.sub}
642
+ onClick={() => setAppDefault(`default_reasoning:${editAgent}`, r.value)}
643
+ >
644
+ {r.label}
645
+ </button>
646
+ ))}
647
+ </div>
648
+ </div>
649
+ <div className="field">
650
+ <div className="lab">{Icon.lock()} 默认权限模式</div>
651
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 10 }}>
652
+ 当任务自身的选择器保持默认时的运行方式。<strong>计划模式</strong>只提出方案,不修改文件。
653
+ </div>
654
+ <div className="seg" style={{ flexWrap: "wrap", maxWidth: 520 }}>
655
+ {permissionOptions(caps).map((p) => (
656
+ <button
657
+ key={p.label}
658
+ className={permissionVal === p.value ? "on" : ""}
659
+ title={p.sub}
660
+ onClick={() => setAppDefault(`default_permission_mode:${editAgent}`, p.value)}
661
+ >
662
+ {p.label}
663
+ </button>
664
+ ))}
665
+ </div>
666
+ </div>
667
+ </>
668
+ )}
669
+ {section === "agents" && <AgentsSection defaultAgent="claude" onChanged={onAgentsRefresh} />}
670
+ {section === "storage" && <WorktreePrune />}
671
+ {section === "github" && <GitHubSettings />}
672
+ {section === "account" && <AccountSection />}
673
+ {section === "api" && <ApiAccessSection />}
674
+ {section === "setup" && (
675
+ <div className="field">
676
+ <div className="lab">{Icon.bolt()} 首次运行设置</div>
677
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 12 }}>
678
+ 重新运行引导式设置,可以重新连接 Claude、在订阅账号与 API 密钥之间切换,或添加新项目。你现有的项目和会话不会受影响。
679
+ </div>
680
+ <button className="btn btn-line" onClick={onRerunSetup} style={{ alignSelf: "flex-start" }}>
681
+ {Icon.restore()} 重新运行设置向导
682
+ </button>
683
+ </div>
684
+ )}
685
+ </div>
686
+ </div>
687
+ </div>
688
+ </>
689
+ );
690
+ }