@spzxg/cli-ui 0.6.2 → 0.6.6

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 (133) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +165 -19
  3. package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +17 -10
  11. package/src/board/BrowserPane.tsx +346 -115
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +54 -31
  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 +94 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/frameQuote.ts +2 -2
  23. package/src/board/panels.ts +33 -0
  24. package/src/board/textLoad.ts +9 -7
  25. package/src/board/workspace.ts +53 -14
  26. package/src/chat/CliChat.tsx +93 -40
  27. package/src/chat/Composer.tsx +44 -42
  28. package/src/chat/RunControls.tsx +99 -0
  29. package/src/chat/configuration.ts +15 -0
  30. package/src/chat/draft.ts +41 -0
  31. package/src/chat/export.ts +2 -2
  32. package/src/components.tsx +13 -0
  33. package/src/core/blocks.ts +17 -6
  34. package/src/core/callbacks.ts +8 -0
  35. package/src/core/client.ts +103 -19
  36. package/src/core/conversation.ts +4 -3
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +31 -14
  40. package/src/core/store.ts +151 -53
  41. package/src/core/types.ts +64 -9
  42. package/src/elements.tsx +135 -29
  43. package/src/embed.ts +6 -8
  44. package/src/index.ts +24 -17
  45. package/src/layout/CliSplit.tsx +1 -1
  46. package/src/layout/split.ts +67 -30
  47. package/src/msglist/CliMsgList.tsx +19 -13
  48. package/src/operator/branchFlow.ts +35 -0
  49. package/src/operator/index.ts +9 -0
  50. package/src/operator/liveStream.ts +1 -0
  51. package/src/operator/node/AgentConnect.tsx +624 -0
  52. package/src/operator/node/AppearancePanel.tsx +35 -0
  53. package/src/operator/node/BranchHistory.tsx +67 -0
  54. package/src/operator/node/BranchNavigator.tsx +36 -0
  55. package/src/operator/node/BrowserPane.tsx +128 -0
  56. package/src/operator/node/BrowserPopup.tsx +22 -0
  57. package/src/operator/node/Chat.tsx +517 -0
  58. package/src/operator/node/CommandPalette.tsx +213 -0
  59. package/src/operator/node/Composer.tsx +287 -0
  60. package/src/operator/node/InsightsView.tsx +831 -0
  61. package/src/operator/node/Layout.tsx +190 -0
  62. package/src/operator/node/Markdown.tsx +61 -0
  63. package/src/operator/node/Modal.tsx +179 -0
  64. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  65. package/src/operator/node/OnboardingWizard.tsx +250 -0
  66. package/src/operator/node/Orchestrator.tsx +634 -0
  67. package/src/operator/node/ProjectLanding.tsx +86 -0
  68. package/src/operator/node/ProjectsColumn.tsx +137 -0
  69. package/src/operator/node/ProviderAuth.tsx +561 -0
  70. package/src/operator/node/RunControls.tsx +2 -0
  71. package/src/operator/node/RunSwitch.tsx +113 -0
  72. package/src/operator/node/Services.tsx +245 -0
  73. package/src/operator/node/SessionRail.tsx +260 -0
  74. package/src/operator/node/SessionView.tsx +288 -0
  75. package/src/operator/node/SettingsView.tsx +690 -0
  76. package/src/operator/node/TaskBoard.tsx +352 -0
  77. package/src/operator/node/TaskChanges.tsx +530 -0
  78. package/src/operator/node/TaskHero.tsx +50 -0
  79. package/src/operator/node/TasksColumn.tsx +200 -0
  80. package/src/operator/node/Terminal.tsx +143 -0
  81. package/src/operator/node/Transcript.tsx +468 -0
  82. package/src/operator/node/ViewFile.tsx +79 -0
  83. package/src/operator/node/Welcome.tsx +101 -0
  84. package/src/operator/node/WorktreePrune.tsx +333 -0
  85. package/src/operator/node/agents.ts +58 -0
  86. package/src/operator/node/api.ts +40 -0
  87. package/src/operator/node/boardBackend.ts +82 -0
  88. package/src/operator/node/boardEvents.ts +22 -0
  89. package/src/operator/node/boardInteraction.ts +15 -0
  90. package/src/operator/node/boardState.ts +57 -0
  91. package/src/operator/node/boardWorkspace.ts +12 -0
  92. package/src/operator/node/branchFlow.ts +1 -0
  93. package/src/operator/node/composerDraft.ts +52 -0
  94. package/src/operator/node/fileLinks.ts +81 -0
  95. package/src/operator/node/format.ts +290 -0
  96. package/src/operator/node/github.tsx +234 -0
  97. package/src/operator/node/icons.tsx +88 -0
  98. package/src/operator/node/liveStream.ts +270 -0
  99. package/src/operator/node/modals.tsx +720 -0
  100. package/src/operator/node/navHistory.ts +70 -0
  101. package/src/operator/node/persist.ts +26 -0
  102. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  103. package/src/operator/node/protocol/agents/types.ts +394 -0
  104. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  105. package/src/operator/node/protocol/authFailure.ts +66 -0
  106. package/src/operator/node/protocol/customModels.ts +23 -0
  107. package/src/operator/node/protocol/events.ts +13 -0
  108. package/src/operator/node/protocol/features.ts +70 -0
  109. package/src/operator/node/protocol/internalUsage.ts +1 -0
  110. package/src/operator/node/protocol/promptLimits.ts +52 -0
  111. package/src/operator/node/protocol/storageRetention.ts +13 -0
  112. package/src/operator/node/protocol/types.ts +332 -0
  113. package/src/operator/node/protocol/usageLimit.ts +72 -0
  114. package/src/operator/node/shared.tsx +89 -0
  115. package/src/operator/node/transcriptModel.ts +129 -0
  116. package/src/operator/node/types.ts +296 -0
  117. package/src/operator/node/useGlobalEvents.ts +97 -0
  118. package/src/operator/node/useOrchestrator.ts +696 -0
  119. package/src/operator/node/usePrefs.ts +114 -0
  120. package/src/operator/node/useRecaps.ts +91 -0
  121. package/src/operator/node/useTaskStream.ts +280 -0
  122. package/src/operator.base.css +1806 -0
  123. package/src/operator.css +194 -16
  124. package/src/styles.css +49 -1
  125. package/src/ui/Markdown.tsx +7 -2
  126. package/src/ui/Popover.tsx +82 -0
  127. package/src/ui/TextBlock.tsx +3 -3
  128. package/src/ui/Transcript.tsx +17 -10
  129. package/src/ui/fileLinks.ts +6 -0
  130. package/src/ui/format.ts +1 -1
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -0,0 +1,52 @@
1
+ export type Quote = { id: string; author: string; text: string };
2
+ export type Attachment = {
3
+ key: string; kind: "image" | "file"; name: string; preview: string; path: string;
4
+ status: "uploading" | "ready" | "error"; error?: string;
5
+ };
6
+ export type ForkSource = { id: string; title: string };
7
+ export type ComposerDraft = { sources?: ForkSource[]; forkRequest?: { id: string; signature: string; payload?: string }; text: string; attachments: Attachment[]; quote: Quote | null };
8
+ const memory=new Map<string,{stamp:string;draft:ComposerDraft}>();
9
+ const stamp=(id:string)=>{try{return JSON.stringify([localStorage.getItem(key(id)),localStorage.getItem(`orch:draft:${id}`)]);}catch{return "blocked";}};
10
+ const key = (id: string) => `orch:composer:${id}`;
11
+ export const emptyDraft = (): ComposerDraft => ({ text: "", attachments: [], quote: null });
12
+ export function readDraft(id: string): ComposerDraft {
13
+ const cached=memory.get(id);if(cached?.stamp===stamp(id)) return cached.draft;
14
+ try {
15
+ const raw = localStorage.getItem(key(id));
16
+ if (raw) {
17
+ const d = JSON.parse(raw) as ComposerDraft;
18
+ if (typeof d.text === "string" && Array.isArray(d.attachments)) return { ...d, attachments: d.attachments.map((a) => a.status === "uploading" ? { ...a, preview: "", status: "error", error: "上传已中断,请移除后重新添加。" } : a) };
19
+ }
20
+ return { ...emptyDraft(), text: localStorage.getItem(`orch:draft:${id}`) ?? "" };
21
+ } catch { return emptyDraft(); }
22
+ }
23
+ export const DRAFT_EVENT = "orch:composer-change";
24
+ const sendingTasks = new Set<string>();
25
+ export const isTaskSending = (id: string) => sendingTasks.has(id);
26
+ export function setTaskSending(id: string, sending: boolean) {
27
+ if (sending) sendingTasks.add(id); else sendingTasks.delete(id);
28
+ window.dispatchEvent(new CustomEvent(DRAFT_EVENT, { detail: { id, sending } }));
29
+ }
30
+ export function writeDraft(id: string, draft: ComposerDraft) {
31
+ try {
32
+ if (draft.text || draft.attachments.length || draft.quote || draft.sources?.length) localStorage.setItem(key(id), JSON.stringify(draft));
33
+ else localStorage.removeItem(key(id));
34
+ localStorage.removeItem(`orch:draft:${id}`);
35
+ } catch { /* Storage may be unavailable; the in-memory draft remains usable. */ }
36
+ memory.set(id,{stamp:stamp(id),draft});
37
+ window.dispatchEvent(new CustomEvent(DRAFT_EVENT, { detail: { id, draft } }));
38
+ }
39
+ export function quoteText(quote: Quote): string {
40
+ return `引用 ${quote.author}:\n${quote.text.split("\n").map((line) => `> ${line}`).join("\n")}`;
41
+ }
42
+
43
+ /** Remove only the content acknowledged by the server; preserve edits/quotes added during the request. */
44
+ export function acknowledgeDraft(current: ComposerDraft, sent: ComposerDraft): ComposerDraft {
45
+ const attachments=new Set(sent.attachments.map(a=>a.key));
46
+ const sources=new Set(sent.sources?.map(s=>s.id));
47
+ return {...current,text:current.text===sent.text?"":current.text,
48
+ attachments:current.attachments.filter(a=>!attachments.has(a.key)),
49
+ sources:current.sources?.filter(s=>!sources.has(s.id)),
50
+ quote:JSON.stringify(current.quote)===JSON.stringify(sent.quote)?null:current.quote,
51
+ forkRequest:undefined};
52
+ }
@@ -0,0 +1,81 @@
1
+ // 把智能体消息里指向本地文件的链接改写成能打开的地址。
2
+ //
3
+ // 智能体给的是它工作目录下的路径:[报告](report.md)、[报告](/Users/…/worktree/report.md)、
4
+ // file:///…、带行号的 src/a.ts:12。原样渲染时浏览器把它当成网站根目录下的地址,
5
+ // 打不开。这里改写到 /api/tasks/<id>/files/…(服务端见 lib/taskFiles.ts),
6
+ // Markdown 则改到 /view 预览页渲染。
7
+ import { createContext } from "react";
8
+
9
+ /** 当前渲染的 Markdown 属于哪个任务、相对哪个目录(预览页里是 md 文件所在目录)。 */
10
+ export type FileLinkScope = { taskId: string; baseDir?: string } | null;
11
+ export const FileLinkContext = createContext<FileLinkScope>(null);
12
+
13
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
14
+ const WIN_ABS = /^[a-z]:[\\/]/i;
15
+
16
+ function normalize(p: string): string {
17
+ const abs = p.startsWith("/");
18
+ const out: string[] = [];
19
+ for (const seg of p.split("/")) {
20
+ if (!seg || seg === ".") continue;
21
+ if (seg === ".." && out.length && out[out.length - 1] !== "..") out.pop();
22
+ else if (seg === ".." && abs) continue;
23
+ else out.push(seg);
24
+ }
25
+ return (abs ? "/" : "") + out.join("/");
26
+ }
27
+
28
+ export function isMarkdownPath(p: string): boolean {
29
+ return /\.(md|markdown)$/i.test(p);
30
+ }
31
+
32
+ /** 文件在服务端的地址:相对路径用路径式(HTML 的相对资源能解析),绝对路径走 ?path=。 */
33
+ export function taskFileApiUrl(taskId: string, p: string): string {
34
+ const id = encodeURIComponent(taskId);
35
+ if (p.startsWith("/") || p.startsWith("~") || WIN_ABS.test(p)) return `/api/tasks/${id}/files?path=${encodeURIComponent(p)}`;
36
+ return `/api/tasks/${id}/files/${p.split("/").filter(Boolean).map(encodeURIComponent).join("/")}`;
37
+ }
38
+
39
+ export function markdownViewUrl(taskId: string, p: string): string {
40
+ return `/view?task=${encodeURIComponent(taskId)}&path=${encodeURIComponent(p)}`;
41
+ }
42
+
43
+ /**
44
+ * 链接指向本地文件时,返回它的路径(相对任务工作目录,或绝对路径);
45
+ * 网址、锚点、应用自己的地址等返回 null(保持原样)。
46
+ */
47
+ export function localFilePath(href: string | undefined | null, baseDir = ""): string | null {
48
+ let h = String(href ?? "").trim();
49
+ if (!h || h.startsWith("#") || h.startsWith("//") || h.startsWith("/api/") || h.startsWith("/view?")) return null;
50
+ if (/^file:/i.test(h)) h = h.replace(/^file:(\/\/localhost)?(\/\/)?/i, "");
51
+ else if (SCHEME.test(h) && !WIN_ABS.test(h)) return null;
52
+ // 去掉查询和锚点(#L12 之类),再解码(Markdown 解析器会把中文转成 %XX)。
53
+ h = h.replace(/[?#].*$/, "");
54
+ try {
55
+ h = decodeURI(h);
56
+ } catch {
57
+ // 保持原样
58
+ }
59
+ h = h.replace(/\\/g, "/");
60
+ // 编辑器风格的行号:src/a.ts:12 或 :12:5
61
+ h = h.replace(/:(\d+)(:\d+)?$/, "");
62
+ if (!h) return null;
63
+ if (h === "~" || h.startsWith("~/")) return h;
64
+ if (h.startsWith("/") || WIN_ABS.test(h)) return WIN_ABS.test(h) ? h : normalize(h);
65
+ return normalize(baseDir ? `${baseDir.replace(/\/+$/, "")}/${h}` : h);
66
+ }
67
+
68
+ /** 链接改写的最终结果:Markdown → 预览页;其他文件 → 文件接口;不是本地文件 → null。 */
69
+ export function rewriteFileHref(scope: FileLinkScope, href: string | undefined | null): string | null {
70
+ if (!scope) return null;
71
+ const p = localFilePath(href, scope.baseDir);
72
+ if (!p) return null;
73
+ return isMarkdownPath(p) ? markdownViewUrl(scope.taskId, p) : taskFileApiUrl(scope.taskId, p);
74
+ }
75
+
76
+ /** 图片等内嵌资源:总是直接取文件。 */
77
+ export function rewriteFileSrc(scope: FileLinkScope, src: string | undefined | null): string | null {
78
+ if (!scope) return null;
79
+ const p = localFilePath(src, scope.baseDir);
80
+ return p ? taskFileApiUrl(scope.taskId, p) : null;
81
+ }
@@ -0,0 +1,290 @@
1
+ // Pure formatting + derivation helpers shared across the orchestrator modules.
2
+ import type { AskQuestion, AskAnswers } from "./protocol/types";
3
+ import type { Msg, TaskRow, AgentCapabilities, AgentInfo } from "./types";
4
+ import type { InternalUsageEstimate } from "./types";
5
+
6
+ // Compact token count: 1234 → "1.2k", 1_200_000 → "1.2M".
7
+ export function fmtTokens(n: number): string {
8
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`;
9
+ if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`;
10
+ return String(n);
11
+ }
12
+ // Human byte size: 1536 → "1.5 KB", 5_242_880 → "5.0 MB". Base-1024.
13
+ export function fmtBytes(n: number): string {
14
+ if (n <= 0) return "0 B";
15
+ const units = ["B", "KB", "MB", "GB", "TB"];
16
+ const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
17
+ const v = n / 1024 ** i;
18
+ return `${i === 0 ? v : v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
19
+ }
20
+ // Dollar cost: sub-cent shows "<$0.01"; otherwise 2–3 sig digits after the point.
21
+ export function fmtCost(n: number): string {
22
+ if (n <= 0) return "$0.00";
23
+ if (n < 0.01) return "<$0.01";
24
+ return `$${n < 1 ? n.toFixed(3) : n.toFixed(2)}`;
25
+ }
26
+
27
+ export function fmtJobCost(e: InternalUsageEstimate): string {
28
+ const cost = e.cost_usd <= 0 ? "$0.00" : e.cost_usd < 0.01 ? "<$0.01" : `$${e.cost_usd.toFixed(2)}`;
29
+ return `约 ${fmtTokens(e.tokens)} tokens(约 ${cost})`;
30
+ }
31
+
32
+ // ---------- the usage chip (tokens + cost, honestly) ----------
33
+
34
+ // A task's cumulative tokens split by what they actually represent. The raw
35
+ // `total_tokens` sums all four buckets, and in real sessions ~90%+ of it is
36
+ // prompt-cache READS — the same context re-sent on every turn and billed at ~10%
37
+ // of the input rate. Leading with that reads as "this task burned 3.8M tokens"
38
+ // when the model only ever processed ~250k of new material, which is what scares
39
+ // people off. So the chip leads with `fresh` (tokens seen for the first time:
40
+ // in/out plus cache WRITES, which are billed above input rate) and carries cache
41
+ // reads as secondary detail. Defensive ?? 0s: a task row can predate the fields.
42
+ export interface UsageSplit {
43
+ total: number; // every bucket summed — what task.total_tokens holds
44
+ fresh: number; // in/out + cache writes: material the model processed anew
45
+ inOut: number; // prompt + completion tokens, uncached
46
+ cacheWrite: number; // context written into the cache (billed ~1.25× input)
47
+ cacheRead: number; // context re-read from the cache (billed ~0.1× input)
48
+ }
49
+ export function usageSplit(t: Pick<TaskRow, "total_tokens" | "cache_read_tokens" | "cache_creation_tokens">): UsageSplit {
50
+ const total = t.total_tokens ?? 0;
51
+ const cacheRead = t.cache_read_tokens ?? 0;
52
+ const cacheWrite = t.cache_creation_tokens ?? 0;
53
+ return { total, cacheRead, cacheWrite, inOut: Math.max(0, total - cacheRead - cacheWrite), fresh: Math.max(0, total - cacheRead) };
54
+ }
55
+
56
+ /**
57
+ * How to present an agent's dollar figure. Two independent questions:
58
+ *
59
+ * - Is the number a MEASUREMENT or an estimate? `costIsEstimated` answers that
60
+ * (Codex reports tokens only, so its figure is tokens × published prices).
61
+ * - Is the number MONEY THE USER SPENDS? Only under api-key auth. On a Max/Pro
62
+ * (or ChatGPT) subscription the marginal cost of a turn is $0 — the SDK's
63
+ * `total_cost_usd` is what the same tokens would have cost through the API,
64
+ * and what's actually consumed is plan quota. Showing a bare "$4.20" there
65
+ * reads as a bill for something that was included.
66
+ *
67
+ * Either one makes the figure approximate-in-meaning, so both get an `~` plus a
68
+ * tooltip clause saying which it is. An unknown account (bundle still loading,
69
+ * agent not connected) keeps the plain billed presentation — we won't claim a
70
+ * turn was covered by a plan we can't see.
71
+ */
72
+ export interface CostDisplay {
73
+ show: boolean; // render a dollar figure at all
74
+ approx: boolean; // prefix it with ~
75
+ note: string; // tooltip clause explaining what the figure means ("" = a plain billed charge)
76
+ }
77
+ export function costDisplay(agent: AgentInfo | undefined): CostDisplay {
78
+ const caps = agent?.capabilities;
79
+ const estimated = caps?.costIsEstimated === true;
80
+ const show = caps?.reportsCostUsd !== false || estimated;
81
+ const subscription = agent?.account?.method === "subscription";
82
+ const bedrock = agent?.account?.method === "bedrock";
83
+ const plan = agent?.account?.plan;
84
+ // "Max"/"Pro"/"ChatGPT Plus" → "your Max plan"; unknown/"API" → "your plan".
85
+ const planName = plan && !/^api$/i.test(plan) ? `你的 ${plan} 订阅` : "你的订阅";
86
+ const source = estimated ? "按 token 数 × 官方 API 价格估算" : "等同 API 价格的折算值";
87
+ const note = bedrock
88
+ ? "由 Claude Code 根据 token 用量与公开价格估算;请以你的 AWS 账单为准"
89
+ : subscription
90
+ ? `${source}:本次使用 ${planName} 登录运行,消耗的是套餐额度,并非实际账单`
91
+ : estimated
92
+ ? source
93
+ : "";
94
+ return { show, approx: subscription || bedrock || estimated, note };
95
+ }
96
+
97
+ // The usage chip's tooltip: the full breakdown the compact chip can't fit, one
98
+ // fact per line. Exact counts here (the chip rounds) — this is the view someone
99
+ // opens precisely because the rounded number surprised them.
100
+ export function usageTooltip(split: UsageSplit, costUsd: number, cost: CostDisplay): string {
101
+ const n = (v: number) => v.toLocaleString();
102
+ const lines = [
103
+ `本任务新增 ${n(split.fresh)} tokens:输入/输出 ${n(split.inOut)} · 写入缓存 ${n(split.cacheWrite)}`,
104
+ ];
105
+ if (split.cacheRead > 0) {
106
+ lines.push(`缓存读取 ${n(split.cacheRead)}(每轮重复读取的上下文,按输入价格的约 10% 计费)`);
107
+ lines.push(`合计 ${n(split.total)} tokens`);
108
+ }
109
+ if (cost.show && costUsd > 0) {
110
+ lines.push(`${cost.approx ? "约 " : ""}${fmtCost(costUsd)}${cost.note ? `(${cost.note})` : "(实际计费)"}`);
111
+ }
112
+ return lines.join("\n");
113
+ }
114
+
115
+ // Context window: the input-side tokens of the latest turn ≈ how full that
116
+ // window currently is. The size comes from the agent's capability descriptor
117
+ // (capabilities.models[].contextWindow) — Codex windows differ from Claude's, so
118
+ // it can't be a static Claude-only table. `caps.models` is looked up by the
119
+ // task's configured model value; unknown/Default falls back to the widest model
120
+ // the agent offers, then a conservative constant.
121
+ export const DEFAULT_CONTEXT_WINDOW = 200_000;
122
+ export function contextWindowOf(model: string | null | undefined, caps?: AgentCapabilities): number {
123
+ const models = caps?.models ?? [];
124
+ if (model) {
125
+ const hit = models.find((m) => m.value === model);
126
+ if (hit) return hit.contextWindow;
127
+ const id = model.toLowerCase();
128
+ if (id.includes("[1m]") || id.includes("claude-sonnet-5") || id.includes("claude-fable-5")) return 1_000_000;
129
+ return DEFAULT_CONTEXT_WINDOW;
130
+ }
131
+ // Default (null) model → the driver picks its own; approximate with the widest
132
+ // window it offers so the gauge doesn't over-report fullness.
133
+ const widest = models.reduce((mx, m) => Math.max(mx, m.contextWindow), 0);
134
+ return widest || DEFAULT_CONTEXT_WINDOW;
135
+ }
136
+ export function contextPct(tokens: number, model: string | null | undefined, caps?: AgentCapabilities): number {
137
+ return Math.round((tokens / contextWindowOf(model, caps)) * 1000) / 10;
138
+ }
139
+
140
+ // Friendly name for a resolved model id — the badge that answers "which model
141
+ // did this turn actually run on?". The VERSION is the point: family aliases move
142
+ // (today "opus" resolves to claude-opus-5, last month claude-opus-4-8), so a bare
143
+ // "Opus" badge tells you nothing. Parse family + version out of the id first
144
+ // ("claude-opus-5" -> "Opus 5", "claude-opus-4-8-20251101" -> "Opus 4.8") and
145
+ // keep the `[1m]` marker, since the 1M variant is a distinct run mode.
146
+ // Non-Claude ids (Codex's "gpt-5.1-codex-max") carry no such version shape —
147
+ // those fall through to the agent's capability labels, matched longest-first so
148
+ // a shorter value can't shadow a more specific one. Raw id is the last resort.
149
+ export function modelLabel(id: string | null, caps?: AgentCapabilities): string {
150
+ if (!id) return "";
151
+ const s = id.toLowerCase();
152
+ const long = s.includes("[1m]") ? " (1M)" : "";
153
+ const fam = ["fable", "opus", "sonnet", "haiku"].find((f) => s.includes(f));
154
+ if (fam) {
155
+ const cap = fam[0].toUpperCase() + fam.slice(1);
156
+ const v = s.match(new RegExp(`${fam}-(\\d+)(?:-(\\d+))?`));
157
+ // Deliberately NOT the capability label here: an id we can't read a version
158
+ // out of shouldn't be badged with a version we're only guessing at.
159
+ return v ? `${cap} ${v[1]}${v[2] ? `.${v[2]}` : ""}${long}` : `${cap}${long}`;
160
+ }
161
+ const hit = (caps?.models ?? [])
162
+ .filter((o) => s.includes(o.value.toLowerCase()))
163
+ .sort((a, b) => b.value.length - a.value.length)[0];
164
+ return hit ? hit.label : id;
165
+ }
166
+
167
+ // Phrase AskUserQuestion answers as a reply, for the reload fallback where the
168
+ // turn is no longer parked and we resume the session with a normal message.
169
+ export function formatAnswersText(questions: AskQuestion[], answers: AskAnswers): string {
170
+ const lines = questions.map((q, i) => {
171
+ const picked = (answers[i] ?? []).filter((s) => s && s.trim());
172
+ return `- ${q.header || q.question}:${picked.length ? picked.join("、") : "(未选择)"}`;
173
+ });
174
+ return `回答你的问题:\n${lines.join("\n")}`;
175
+ }
176
+ // Absolute wall-clock stamp for a transcript message ("3:42 PM", locale-aware).
177
+ // Messages from an earlier day prefix the date, so a task resumed days later
178
+ // still reads right. Absolute on purpose: MessageView is memoized, so a
179
+ // relative "2m ago" would freeze at whatever it said when the row rendered.
180
+ export function clockTime(ts: number): string {
181
+ const d = new Date(ts);
182
+ const time = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
183
+ if (d.toDateString() === new Date().toDateString()) return time;
184
+ return `${d.toLocaleDateString([], { month: "short", day: "numeric" })}, ${time}`;
185
+ }
186
+ export function relTime(ts: number): string {
187
+ const d = Date.now() - ts;
188
+ const m = Math.floor(d / 60000);
189
+ if (m < 1) return "刚刚";
190
+ if (m < 60) return `${m} 分钟前`;
191
+ const h = Math.floor(m / 60);
192
+ if (h < 24) return `${h} 小时前`;
193
+ return `${Math.floor(h / 24)} 天前`;
194
+ }
195
+ // How long a task has been waiting on the user, spelled out for the "need you"
196
+ // dropdown ("waiting for 3 hours"). Coarser and more verbose than relTime — this
197
+ // is the only subline a row gets, so it reads as prose rather than a chip.
198
+ export function waitedFor(since: number): string {
199
+ const s = Math.max(0, Math.round((Date.now() - since) / 1000));
200
+ if (s < 45) return "几秒";
201
+ const m = Math.round(s / 60);
202
+ if (m < 1) return "1 分钟";
203
+ if (m < 60) return `${m} 分钟`;
204
+ const h = Math.round(m / 60);
205
+ if (h < 24) return `${h} 小时`;
206
+ const d = Math.round(h / 24);
207
+ return `${d} 天`;
208
+ }
209
+ export function duration(start: number, end: number | null): string {
210
+ if (!end) return "进行中";
211
+ const s = Math.max(0, Math.round((end - start) / 1000));
212
+ if (s < 60) return `${s}s`;
213
+ const m = Math.floor(s / 60);
214
+ if (m < 60) return `${m}m ${s % 60}s`;
215
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
216
+ }
217
+
218
+ // A task is "waiting on you" when its awaiting_input flag is set — Claude either
219
+ // ended its turn mid-task or is parked on an AskUserQuestion. The flag is the
220
+ // single source of truth (cleared the instant the next turn starts / a question
221
+ // is answered), so this holds even while the turn is technically still live and
222
+ // parked on the question — that's exactly the case the task list must surface.
223
+ export const isAwaiting = (t: TaskRow) =>
224
+ t.status === "in_progress" && !!t.awaiting_input;
225
+
226
+ // The titles of a task's unfinished blockers (dependencies not yet 'done'). A
227
+ // task with any of these is "blocked" and can't be started until they complete.
228
+ // A cancelled dependency doesn't block — it's terminal and will never finish,
229
+ // so waiting on it would deadlock the dependent task forever.
230
+ export const blockerTitles = (t: TaskRow, byId: Map<string, TaskRow>): string[] =>
231
+ (t.depends_on ?? [])
232
+ .map((id) => byId.get(id))
233
+ .filter((b): b is TaskRow => !!b && b.status !== "done" && b.status !== "cancelled")
234
+ .map((b) => b.title);
235
+
236
+ // add/del/ctx class for a diff line's sign — shared by the peek and full views.
237
+ export const diffCls = (sign: "+" | "-" | " ") => (sign === "+" ? "add" : sign === "-" ? "del" : "ctx");
238
+
239
+ // group flat messages into per-generation sessions, pulling out the /clear summaries
240
+ export function buildSessions(messages: Msg[]) {
241
+ const summaryByGen: Record<number, string> = {};
242
+ for (const m of messages) if (m.role === "session_break") summaryByGen[m.generation] = m.content;
243
+ // Queued follow-ups are excluded here — they haven't run yet, so SessionView
244
+ // renders them in a pinned block below the live "thinking" indicator instead
245
+ // of interleaved with the committed transcript.
246
+ const committed = messages.filter((m) => m.role !== "queued");
247
+ const gens = Array.from(new Set(committed.filter((m) => m.role !== "session_break").map((m) => m.generation))).sort((a, b) => a - b);
248
+ return gens.map((n) => ({
249
+ n,
250
+ summaryBefore: summaryByGen[n - 1] ?? null,
251
+ messages: committed.filter((m) => m.generation === n && m.role !== "session_break"),
252
+ }));
253
+ }
254
+
255
+ // ---------- chat attachments (images + large text pastes) ----------
256
+ // An upload travels inside the message text as one marker line per file:
257
+ // "[Attached image: /abs/path.png]" for images, "[Attached file: /abs/path.txt]"
258
+ // for a big text paste diverted to a file (see PASTE_ATTACH_THRESHOLD). The
259
+ // same string serves both sides — Claude Code opens the absolute path with its
260
+ // Read tool (rendering images natively, reading text files as text), and the
261
+ // transcript strips the marker back out to render an inline thumbnail (image)
262
+ // or a file chip (text). The serving URL is derived from the path's
263
+ // uploads/<task>/<file> tail, so no extra columns or event fields are needed.
264
+ export const attachmentMarker = (absPath: string) => `[Attached image: ${absPath}]`;
265
+ export const fileAttachmentMarker = (absPath: string) => `[Attached file: ${absPath}]`;
266
+ const ATTACHMENT_RE = /^\[Attached (image|file): (.+)\]$/;
267
+
268
+ export interface MsgAttachment { path: string; url: string; kind: "image" | "file"; name: string }
269
+
270
+ // Split a user message into displayable text + attachment chips. Marker lines
271
+ // whose path doesn't end in uploads/<task>/<file> (hand-typed lookalikes) stay
272
+ // in the text untouched.
273
+ export function splitAttachments(content: string): { text: string; attachments: MsgAttachment[] } {
274
+ if (!content.includes("[Attached image: ") && !content.includes("[Attached file: ")) {
275
+ return { text: content, attachments: [] };
276
+ }
277
+ const attachments: MsgAttachment[] = [];
278
+ const kept: string[] = [];
279
+ for (const line of content.split("\n")) {
280
+ const m = ATTACHMENT_RE.exec(line.trim());
281
+ const parts = m ? m[2].split(/[\\/]/).filter(Boolean) : [];
282
+ if (m && parts.length >= 3 && parts[parts.length - 3] === "uploads") {
283
+ const [taskId, file] = parts.slice(-2);
284
+ attachments.push({ path: m[2], url: `/api/tasks/${taskId}/uploads/${file}`, kind: m[1] === "image" ? "image" : "file", name: file });
285
+ } else {
286
+ kept.push(line);
287
+ }
288
+ }
289
+ return { text: kept.join("\n").trim(), attachments };
290
+ }
@@ -0,0 +1,234 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { Icon } from "./icons";
5
+ import { jget, jsend } from "./api";
6
+ import { LoadNote, Skel, ErrNote } from "./shared";
7
+ import type { GhStatusT, GhLoginT, GhRepoT } from "./types";
8
+
9
+ // The "Clone from GitHub" pane of the new-project modal: shows the guided
10
+ // connect step when the workspace isn't logged in, the user's repo list when
11
+ // it is, and always accepts a pasted URL as the escape hatch.
12
+ export function GitHubClonePicker({ value, onChange }: { value: string; onChange: (spec: string, shortName?: string) => void }) {
13
+ const [status, setStatus] = useState<GhStatusT | null>(null);
14
+ const refresh = useCallback(() => {
15
+ jget<GhStatusT>("/api/github/status")
16
+ .then(setStatus)
17
+ .catch(() => setStatus({ installed: false, authenticated: false, login: null }));
18
+ }, []);
19
+ useEffect(() => { refresh(); }, [refresh]);
20
+
21
+ if (!status) return <LoadNote style={{ padding: 0, marginBottom: 14 }}>正在检查 GitHub 连接…</LoadNote>;
22
+ return (
23
+ <>
24
+ {status.authenticated ? (
25
+ <GhRepoList selected={value} onPick={(r) => onChange(r, r.split("/").pop())} />
26
+ ) : status.installed ? (
27
+ <GitHubConnect onConnected={refresh} />
28
+ ) : (
29
+ <div className="hlp" style={{ marginBottom: 14 }}>
30
+ 此工作区没有可用的 GitHub CLI(<code>gh</code>),因此只能克隆公开仓库 —— 请在下方粘贴地址。
31
+ </div>
32
+ )}
33
+ <div className="field">
34
+ <div className="lab">仓库 {status.authenticated && <span className="opt">— 也可直接粘贴地址</span>}</div>
35
+ <input type="text" className="ctx-mono" value={value} placeholder="owner/repo 或 https://github.com/owner/repo"
36
+ onChange={(e) => onChange(e.target.value)} />
37
+ </div>
38
+ </>
39
+ );
40
+ }
41
+
42
+ // Guided `gh auth login` device flow: one click starts it, the one-time code +
43
+ // verification URL appear right here (no terminal scrollback), and we poll
44
+ // until GitHub confirms. The session lives server-side, so closing the modal
45
+ // mid-login and reopening resumes the same code.
46
+ export function GitHubConnect({ onConnected }: { onConnected: () => void }) {
47
+ const [login, setLogin] = useState<GhLoginT | null>(null);
48
+ const [busy, setBusy] = useState(false);
49
+ const [copied, setCopied] = useState(false);
50
+ const done = useRef(false);
51
+
52
+ // Rejoin a login that's already underway (e.g. the modal was closed mid-flow).
53
+ useEffect(() => {
54
+ jget<GhLoginT>("/api/github/login").then((l) => { if (l.status !== "idle") setLogin(l); }).catch(() => {});
55
+ }, []);
56
+
57
+ const start = async () => {
58
+ setBusy(true);
59
+ try {
60
+ setLogin(await jsend<GhLoginT>("/api/github/login", "POST"));
61
+ } catch (e) {
62
+ setLogin({ status: "error", code: null, url: null, user: null, error: e instanceof Error ? e.message : String(e) });
63
+ } finally {
64
+ setBusy(false);
65
+ }
66
+ };
67
+ const cancel = async () => {
68
+ await jsend("/api/github/login", "DELETE").catch(() => {});
69
+ setLogin(null);
70
+ };
71
+
72
+ // Poll while gh waits for the user to authorize on github.com.
73
+ useEffect(() => {
74
+ if (!login || (login.status !== "starting" && login.status !== "awaiting")) return;
75
+ const t = setInterval(() => {
76
+ jget<GhLoginT>("/api/github/login").then((l) => {
77
+ setLogin(l);
78
+ if (l.status === "success" && !done.current) { done.current = true; onConnected(); }
79
+ }).catch(() => {});
80
+ }, 2000);
81
+ return () => clearInterval(t);
82
+ }, [login, onConnected]);
83
+
84
+ const copyCode = () => {
85
+ if (!login?.code) return;
86
+ navigator.clipboard?.writeText(login.code).then(() => {
87
+ setCopied(true);
88
+ setTimeout(() => setCopied(false), 1500);
89
+ }).catch(() => {});
90
+ };
91
+
92
+ if (!login || login.status === "idle") {
93
+ return (
94
+ <div className="field">
95
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 8 }}>
96
+ 连接一次 GitHub,就能从你的仓库列表中挑选 —— 包括私有仓库。登录状态保存在此工作区中,重启后依然有效。
97
+ </div>
98
+ <button className="btn btn-line" disabled={busy} onClick={start} style={{ alignSelf: "flex-start" }}>
99
+ {Icon.github()} {busy ? "启动中…" : "连接 GitHub"}
100
+ </button>
101
+ </div>
102
+ );
103
+ }
104
+ if (login.status === "starting") return <LoadNote style={{ padding: 0, marginBottom: 14 }}>正在启动 GitHub 登录…</LoadNote>;
105
+ if (login.status === "awaiting") {
106
+ return (
107
+ <div className="field" style={{ border: "1px solid var(--line-strong)", borderRadius: "var(--r)", background: "var(--raise)", padding: "14px 16px" }}>
108
+ <div className="hlp" style={{ marginTop: 0 }}>在 GitHub 上输入这个一次性验证码以完成连接:</div>
109
+ <div style={{ display: "flex", alignItems: "center", gap: 12, margin: "10px 0" }}>
110
+ <button onClick={copyCode} title="点击复制"
111
+ style={{ fontFamily: "var(--mono)", fontSize: 24, fontWeight: 700, letterSpacing: ".12em", color: "var(--ink)", background: "none", border: "1px dashed var(--line-strong)", borderRadius: 8, padding: "6px 14px", cursor: "pointer" }}>
112
+ {login.code}
113
+ </button>
114
+ <span className="hlp" style={{ margin: 0 }}>{copied ? "已复制!" : "点击复制"}</span>
115
+ </div>
116
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
117
+ <a className="btn btn-accent" href={login.url ?? "https://github.com/login/device"} target="_blank" rel="noreferrer">
118
+ {Icon.github()} 打开 github.com/login/device
119
+ </a>
120
+ <button className="btn btn-ghost" onClick={cancel}>取消</button>
121
+ </div>
122
+ <div className="hlp" style={{ marginBottom: 0 }}>等待你在 github.com 上完成授权…</div>
123
+ </div>
124
+ );
125
+ }
126
+ if (login.status === "error") {
127
+ return (
128
+ <div className="field">
129
+ <div className="hlp" style={{ marginTop: 0, color: "var(--red)" }}>⚠ GitHub 登录失败:{login.error ?? "未知错误"}</div>
130
+ <button className="btn btn-line" onClick={start} style={{ alignSelf: "flex-start" }}>{Icon.restore()} 重试</button>
131
+ </div>
132
+ );
133
+ }
134
+ // success
135
+ return <div className="hlp" style={{ marginBottom: 14 }}>{Icon.check()} 已连接,账号 <strong>{login.user}</strong> —— 正在加载你的仓库…</div>;
136
+ }
137
+
138
+ // Searchable list of the user's repos (most recently pushed first).
139
+ export function GhRepoList({ selected, onPick }: { selected: string; onPick: (nameWithOwner: string) => void }) {
140
+ const [repos, setRepos] = useState<GhRepoT[] | null>(null);
141
+ const [error, setError] = useState<string | null>(null);
142
+ const [q, setQ] = useState("");
143
+ const load = useCallback(() => {
144
+ setError(null);
145
+ setRepos(null);
146
+ jget<GhRepoT[]>("/api/github/repos").then(setRepos).catch((e) => setError(e instanceof Error ? e.message : String(e)));
147
+ }, []);
148
+ useEffect(() => { load(); }, [load]);
149
+
150
+ if (error) return <ErrNote style={{ marginBottom: 14 }} onRetry={load}>无法加载你的仓库:{error}</ErrNote>;
151
+ if (!repos) {
152
+ return (
153
+ <div className="field">
154
+ <div className="lab">{Icon.github()} 你的仓库</div>
155
+ <div style={{ border: "1px solid var(--line-strong)", borderRadius: "var(--r)", background: "var(--raise)" }} aria-hidden>
156
+ {[46, 60, 38, 52].map((w, i) => (
157
+ <div key={i} className="skel-lrow">
158
+ <Skel w={14} h={14} r={4} />
159
+ <Skel w={`${w}%`} h={11} />
160
+ </div>
161
+ ))}
162
+ </div>
163
+ </div>
164
+ );
165
+ }
166
+ const filtered = repos.filter((r) => r.nameWithOwner.toLowerCase().includes(q.trim().toLowerCase()));
167
+ return (
168
+ <div className="field">
169
+ <div className="lab">{Icon.github()} 你的仓库</div>
170
+ <input type="text" value={q} placeholder="搜索仓库…" onChange={(e) => setQ(e.target.value)} style={{ marginBottom: 8 }} />
171
+ <div style={{ border: "1px solid var(--line-strong)", borderRadius: "var(--r)", background: "var(--raise)", maxHeight: 220, overflowY: "auto" }}>
172
+ {filtered.length === 0 && <div className="hlp" style={{ padding: "12px 14px", margin: 0 }}>没有匹配的仓库。</div>}
173
+ {filtered.map((r) => (
174
+ <button key={r.nameWithOwner} onClick={() => onPick(r.nameWithOwner)} title={r.nameWithOwner}
175
+ style={{ display: "flex", alignItems: "center", gap: 9, width: "100%", textAlign: "left", padding: "8px 13px", borderBottom: "1px solid var(--line)", color: "var(--ink)", fontSize: 13, background: selected === r.nameWithOwner ? "var(--accent-soft, rgba(194,96,60,.12))" : "none" }}>
176
+ <span style={{ color: selected === r.nameWithOwner ? "var(--accent)" : "var(--ink-4)", display: "inline-flex" }}>
177
+ {selected === r.nameWithOwner ? Icon.check() : Icon.git()}
178
+ </span>
179
+ <span style={{ fontFamily: "var(--mono)", fontSize: 12.5, whiteSpace: "nowrap" }}>{r.nameWithOwner}</span>
180
+ {r.isPrivate && <span style={{ color: "var(--ink-4)", display: "inline-flex" }} title="私有">{Icon.lock()}</span>}
181
+ <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--ink-3)", fontSize: 12 }}>{r.description}</span>
182
+ </button>
183
+ ))}
184
+ </div>
185
+ </div>
186
+ );
187
+ }
188
+
189
+ // Settings → GitHub: connection status, the same guided connect flow, and
190
+ // disconnect. Useful for checking the login survived a workspace restart.
191
+ export function GitHubSettings() {
192
+ const [status, setStatus] = useState<GhStatusT | null>(null);
193
+ const [err, setErr] = useState<string | null>(null);
194
+ const refresh = useCallback(() => {
195
+ jget<GhStatusT>("/api/github/status")
196
+ .then(setStatus)
197
+ .catch(() => setStatus({ installed: false, authenticated: false, login: null }));
198
+ }, []);
199
+ useEffect(() => { refresh(); }, [refresh]);
200
+
201
+ const disconnect = async () => {
202
+ setErr(null);
203
+ try {
204
+ await jsend("/api/github/logout", "POST");
205
+ refresh();
206
+ } catch (e) {
207
+ setErr(e instanceof Error ? e.message : String(e));
208
+ }
209
+ };
210
+
211
+ if (!status) return <LoadNote style={{ padding: 0 }}>正在检查 GitHub 连接…</LoadNote>;
212
+ if (!status.installed) {
213
+ return <div className="hlp">此工作区没有可用的 GitHub CLI(<code>gh</code>),因此这里无需连接。创建项目时仍可通过地址克隆公开仓库。</div>;
214
+ }
215
+ return (
216
+ <div className="field">
217
+ <div className="lab">{Icon.github()} GitHub 账号</div>
218
+ <div className="hlp" style={{ marginTop: 0, marginBottom: 12 }}>
219
+ 用于在创建项目时列出并克隆你的仓库(包括私有仓库),以及在任务和终端中执行 <code>git push/pull</code>。登录状态保存在此工作区中,重启后依然有效。
220
+ </div>
221
+ {status.authenticated ? (
222
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
223
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 7, color: "var(--ink)" }}>
224
+ {Icon.check()} 已连接,账号 <strong>{status.login ?? "未知"}</strong>
225
+ </span>
226
+ <button className="btn btn-line btn-sm" onClick={disconnect}>{Icon.x()} 断开连接</button>
227
+ </div>
228
+ ) : (
229
+ <GitHubConnect onConnected={refresh} />
230
+ )}
231
+ {err && <ErrNote style={{ marginTop: 10 }}>{err}</ErrNote>}
232
+ </div>
233
+ );
234
+ }