@spzxg/cli-ui 0.6.1 → 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 (133) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +145 -13
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BFpRZXn4.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-B9tqMA-e.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DIavJzUG.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-BKHhqcoa.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 +113 -53
  26. package/src/chat/Composer.tsx +68 -57
  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 +16 -6
  35. package/src/core/config.ts +11 -15
  36. package/src/core/conversation.ts +4 -4
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +24 -14
  40. package/src/core/store.ts +83 -26
  41. package/src/core/types.ts +34 -0
  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/msglist/CliMsgList.tsx +10 -10
  47. package/src/operator/branchFlow.ts +35 -0
  48. package/src/operator/index.ts +9 -0
  49. package/src/operator/liveStream.ts +1 -0
  50. package/src/operator/node/AgentConnect.tsx +624 -0
  51. package/src/operator/node/AppearancePanel.tsx +35 -0
  52. package/src/operator/node/BranchHistory.tsx +67 -0
  53. package/src/operator/node/BranchNavigator.tsx +36 -0
  54. package/src/operator/node/BrowserPane.tsx +128 -0
  55. package/src/operator/node/BrowserPopup.tsx +22 -0
  56. package/src/operator/node/Chat.tsx +517 -0
  57. package/src/operator/node/CommandPalette.tsx +213 -0
  58. package/src/operator/node/Composer.tsx +287 -0
  59. package/src/operator/node/InsightsView.tsx +831 -0
  60. package/src/operator/node/Layout.tsx +190 -0
  61. package/src/operator/node/Markdown.tsx +61 -0
  62. package/src/operator/node/Modal.tsx +179 -0
  63. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  64. package/src/operator/node/OnboardingWizard.tsx +250 -0
  65. package/src/operator/node/Orchestrator.tsx +634 -0
  66. package/src/operator/node/ProjectLanding.tsx +86 -0
  67. package/src/operator/node/ProjectsColumn.tsx +137 -0
  68. package/src/operator/node/ProviderAuth.tsx +561 -0
  69. package/src/operator/node/RunControls.tsx +2 -0
  70. package/src/operator/node/RunSwitch.tsx +113 -0
  71. package/src/operator/node/Services.tsx +245 -0
  72. package/src/operator/node/SessionRail.tsx +260 -0
  73. package/src/operator/node/SessionView.tsx +288 -0
  74. package/src/operator/node/SettingsView.tsx +690 -0
  75. package/src/operator/node/TaskBoard.tsx +352 -0
  76. package/src/operator/node/TaskChanges.tsx +530 -0
  77. package/src/operator/node/TaskHero.tsx +50 -0
  78. package/src/operator/node/TasksColumn.tsx +200 -0
  79. package/src/operator/node/Terminal.tsx +143 -0
  80. package/src/operator/node/Transcript.tsx +463 -0
  81. package/src/operator/node/ViewFile.tsx +79 -0
  82. package/src/operator/node/Welcome.tsx +101 -0
  83. package/src/operator/node/WorktreePrune.tsx +333 -0
  84. package/src/operator/node/agents.ts +58 -0
  85. package/src/operator/node/api.ts +40 -0
  86. package/src/operator/node/boardBackend.ts +80 -0
  87. package/src/operator/node/boardEvents.ts +22 -0
  88. package/src/operator/node/boardInteraction.ts +15 -0
  89. package/src/operator/node/boardState.ts +57 -0
  90. package/src/operator/node/boardWorkspace.ts +12 -0
  91. package/src/operator/node/branchFlow.ts +1 -0
  92. package/src/operator/node/composerDraft.ts +52 -0
  93. package/src/operator/node/fileLinks.ts +81 -0
  94. package/src/operator/node/format.ts +290 -0
  95. package/src/operator/node/github.tsx +234 -0
  96. package/src/operator/node/icons.tsx +88 -0
  97. package/src/operator/node/liveStream.ts +270 -0
  98. package/src/operator/node/modals.tsx +720 -0
  99. package/src/operator/node/navHistory.ts +70 -0
  100. package/src/operator/node/persist.ts +26 -0
  101. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  102. package/src/operator/node/protocol/agents/types.ts +394 -0
  103. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  104. package/src/operator/node/protocol/authFailure.ts +66 -0
  105. package/src/operator/node/protocol/customModels.ts +23 -0
  106. package/src/operator/node/protocol/events.ts +13 -0
  107. package/src/operator/node/protocol/features.ts +70 -0
  108. package/src/operator/node/protocol/internalUsage.ts +1 -0
  109. package/src/operator/node/protocol/promptLimits.ts +52 -0
  110. package/src/operator/node/protocol/storageRetention.ts +13 -0
  111. package/src/operator/node/protocol/types.ts +332 -0
  112. package/src/operator/node/protocol/usageLimit.ts +72 -0
  113. package/src/operator/node/shared.tsx +89 -0
  114. package/src/operator/node/transcriptModel.ts +129 -0
  115. package/src/operator/node/types.ts +296 -0
  116. package/src/operator/node/useGlobalEvents.ts +97 -0
  117. package/src/operator/node/useOrchestrator.ts +696 -0
  118. package/src/operator/node/usePrefs.ts +114 -0
  119. package/src/operator/node/useRecaps.ts +91 -0
  120. package/src/operator/node/useTaskStream.ts +279 -0
  121. package/src/operator.base.css +1798 -0
  122. package/src/operator.css +186 -16
  123. package/src/styles.css +51 -0
  124. package/src/ui/Markdown.tsx +7 -2
  125. package/src/ui/Popover.tsx +77 -0
  126. package/src/ui/TextBlock.tsx +3 -3
  127. package/src/ui/Transcript.tsx +10 -8
  128. package/src/ui/fileLinks.ts +6 -0
  129. package/src/ui/format.ts +1 -1
  130. package/src/ui/icons.tsx +1 -0
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-Dk2QGHxn.js +0 -41146
@@ -0,0 +1,200 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Icon } from "./icons";
5
+ import { isAwaiting, relTime } from "./format";
6
+ import { SLABEL, AWAIT_LABEL, SEARCH_MIN, type ProjectRow, type TaskRow, type AgentsBundle, type TaskView } from "./types";
7
+ import { agentLabel } from "./agents";
8
+ import { StatusDot, PriPill, SearchBar, AgentBadge } from "./shared";
9
+ import { TaskCardSkeleton } from "./Layout";
10
+ import { TaskBoard } from "./TaskBoard";
11
+ import { downloadExport } from "./api";
12
+
13
+ function TaskCard({ task, agents, selected, running, blockedBy, onSelect }: { task: TaskRow; agents: AgentsBundle; selected: boolean; running: boolean; blockedBy?: string[]; onSelect: () => void }) {
14
+ const sessionCount = task.started ? task.generation : Math.max(0, task.generation - 1);
15
+ const awaiting = isAwaiting(task);
16
+ const blocked = !!blockedBy?.length && !task.started;
17
+ // Awaiting wins over running: a turn parked on a question is live but really
18
+ // waiting on you, so it should read "waiting", not "working".
19
+ const activity = awaiting ? `等你处理 · ${relTime(task.updated_at)}`
20
+ : running ? "运行中 · 工作中"
21
+ : task.status === "done" ? `已完成 · ${relTime(task.updated_at)}`
22
+ : task.status === "cancelled" ? `已取消 · ${relTime(task.updated_at)}`
23
+ : task.started ? relTime(task.updated_at) : "未开始";
24
+ return (
25
+ <button className={`task ${selected ? "sel" : ""} ${awaiting ? "awaiting" : ""}`} onClick={onSelect}>
26
+ <div className="task-top">
27
+ <StatusDot status={task.status} running={running} awaiting={awaiting} />
28
+ <span className="ttitle">{task.title}</span>
29
+ <span className={`slabel ${awaiting ? "await" : ""}`}>{awaiting ? AWAIT_LABEL : SLABEL[task.status]}</span>
30
+ <AgentBadge label={agentLabel(agents, task.agent)} multi={agents.agents.length > 1} />
31
+ <PriPill p={task.priority} />
32
+ </div>
33
+ {blocked && (task.auto_start ? (
34
+ // Queued to auto-start ≠ plain blocked: this one launches itself the
35
+ // moment its last blocker is marked done.
36
+ <div className="blocked-chip auto" title={`以下任务完成后自动开始:${blockedBy!.join("、")}`}>
37
+ {Icon.bolt()} {blockedBy!.length === 1 ? blockedBy![0] : `${blockedBy!.length} 个任务`}完成后自动开始
38
+ </div>
39
+ ) : (
40
+ <div className="blocked-chip" title={`以下任务完成前受阻:${blockedBy!.join("、")}`}>
41
+ {Icon.lock()} 受阻于 {blockedBy!.length === 1 ? blockedBy![0] : `${blockedBy!.length} 个任务`}
42
+ </div>
43
+ ))}
44
+ {task.description && <div className="tdesc">{task.description}</div>}
45
+ <div className="task-foot">
46
+ <span className="activity">{awaiting ? <span style={{ color: "var(--blue)" }}>●</span> : running ? <span style={{ color: "var(--amber)" }}>●</span> : null}{activity}</span>
47
+ <span className="spacer" />
48
+ {sessionCount > 0 && <span className="activity">{sessionCount} 个会话</span>}
49
+ </div>
50
+ </button>
51
+ );
52
+ }
53
+
54
+ function TaskGroup({ label, tasks, agents, selTaskId, running, blockedBy, onSelect, accent, collapsible, collapsed, onToggle }: { label: string; tasks: TaskRow[]; agents: AgentsBundle; selTaskId: string | null; running: Set<string>; blockedBy: Map<string, string[]>; onSelect: (id: string) => void; accent?: boolean; collapsible?: boolean; collapsed?: boolean; onToggle?: () => void }) {
55
+ if (tasks.length === 0) return null;
56
+ if (collapsible) {
57
+ return (
58
+ <>
59
+ <button className={`task-group-h tgh-btn ${collapsed ? "is-collapsed" : ""}`} onClick={onToggle} title={`${collapsed ? "展开" : "折叠"}「${label}」任务`}>
60
+ {Icon.chevDown({ className: "tgh-chev" })}
61
+ {label} <span className="gcount">{tasks.length}</span><span className="gline" />
62
+ </button>
63
+ {!collapsed && tasks.map((t) => <TaskCard key={t.id} task={t} agents={agents} selected={t.id === selTaskId} running={running.has(t.id)} blockedBy={blockedBy.get(t.id)} onSelect={() => onSelect(t.id)} />)}
64
+ </>
65
+ );
66
+ }
67
+ return (
68
+ <>
69
+ <div className={`task-group-h ${accent ? "needs-you" : ""}`}>{label} <span className="gcount">{tasks.length}</span><span className="gline" /></div>
70
+ {tasks.map((t) => <TaskCard key={t.id} task={t} agents={agents} selected={t.id === selTaskId} running={running.has(t.id)} blockedBy={blockedBy.get(t.id)} onSelect={() => onSelect(t.id)} />)}
71
+ </>
72
+ );
73
+ }
74
+
75
+ // Per-group collapsed flag, persisted in localStorage under `key`.
76
+ function useCollapsed(key: string, def: boolean) {
77
+ const [collapsed, setCollapsed] = useState(def);
78
+ useEffect(() => {
79
+ try {
80
+ const v = localStorage.getItem(key);
81
+ setCollapsed(v === null ? def : v === "1");
82
+ } catch {}
83
+ }, [key, def]);
84
+ const toggle = () => setCollapsed((c) => {
85
+ const next = !c;
86
+ try { localStorage.setItem(key, next ? "1" : "0"); } catch {}
87
+ return next;
88
+ });
89
+ return [collapsed, toggle] as const;
90
+ }
91
+
92
+ export function TasksColumn({ project, agents, tasks, suggested, selTaskId, running, blockedBy, width, loading, view, onSetView, onMoveTask, onSelectTask, onNewTask, onEditContext, onShowSessions, onShowRecap, onEditTask, onStartSuggestion, onAcceptSuggestion, onDismissSuggestion, onCollapse, mobile, onBack }: {
93
+ project: ProjectRow; agents: AgentsBundle; tasks: TaskRow[]; suggested: TaskRow[]; selTaskId: string | null; running: Set<string>; blockedBy: Map<string, string[]>; width: number; loading?: boolean;
94
+ view: TaskView; onSetView: (v: TaskView) => void;
95
+ onMoveTask: (id: string, patch: Partial<Pick<TaskRow, "status" | "suggested">>, orderedIds: string[]) => void;
96
+ onSelectTask: (id: string) => void; onNewTask: () => void; onEditContext: () => void; onShowSessions: () => void; onShowRecap: () => void;
97
+ onEditTask: (id: string) => void; onCollapse: () => void;
98
+ onStartSuggestion: (id: string) => void; onAcceptSuggestion: (id: string) => void; onDismissSuggestion: (id: string) => void;
99
+ mobile?: boolean; onBack?: () => void;
100
+ }) {
101
+ const [query, setQuery] = useState("");
102
+ // Minimize the Done/Cancelled groups so a long backlog of finished (or
103
+ // abandoned) tasks doesn't force scrolling past them. Per-project, persisted
104
+ // so the choice sticks across reloads. Cancelled starts collapsed — it's the
105
+ // graveyard, not the working set.
106
+ const [doneCollapsed, toggleDone] = useCollapsed(`orch_done_collapsed_${project.id}`, false);
107
+ const [cancelledCollapsed, toggleCancelled] = useCollapsed(`orch_cancelled_collapsed_${project.id}`, true);
108
+ const q = query.trim().toLowerCase();
109
+ const match = (t: TaskRow) => !q || t.title.toLowerCase().includes(q) || (t.description ?? "").toLowerCase().includes(q);
110
+ const shown = tasks.filter(match);
111
+ const shownSuggested = suggested.filter(match);
112
+ const needsYou = shown.filter((t) => isAwaiting(t));
113
+ const groups = {
114
+ a: shown.filter((t) => t.status === "in_progress" && !isAwaiting(t)),
115
+ h: shown.filter((t) => t.status === "on_hold" && !isAwaiting(t)),
116
+ r: shown.filter((t) => t.status === "not_started"),
117
+ g: shown.filter((t) => t.status === "done").sort((a, b) => b.updated_at - a.updated_at),
118
+ x: shown.filter((t) => t.status === "cancelled").sort((a, b) => b.updated_at - a.updated_at),
119
+ };
120
+ const canSearch = tasks.length + suggested.length >= SEARCH_MIN;
121
+ const noMatches = q && shown.length === 0 && shownSuggested.length === 0;
122
+ return (
123
+ <div className="col col-tasks" style={{ flexBasis: width }}>
124
+ <div className="proj-banner">
125
+ <div className="pb-row">
126
+ {onBack && <button className="mobile-back" onClick={onBack} title="返回项目列表" aria-label="返回项目列表">{Icon.chevRight({ style: { transform: "rotate(180deg)" } })}</button>}
127
+ <button className="pb-home" onClick={onShowRecap} title="项目回顾 / 概览">
128
+ <span className="pb-pic" style={{ background: project.color }}>{project.name[0]}</span>
129
+ <span className="pb-name">{project.name}</span>
130
+ </button>
131
+ <button className="btn btn-line btn-sm" onClick={onShowSessions} title="该项目下运行过的智能体会话">{Icon.clock()} 会话</button>
132
+ <button className="btn btn-line btn-sm" onClick={onNewTask}>{Icon.plus()} 任务</button>
133
+ <div className="view-toggle" role="tablist" aria-label="任务布局">
134
+ <button className={view === "list" ? "on" : ""} role="tab" aria-selected={view === "list"} title="列表视图" onClick={() => onSetView("list")}>{Icon.list()}</button>
135
+ <button className={view === "board" ? "on" : ""} role="tab" aria-selected={view === "board"} title="看板视图" onClick={() => onSetView("board")}>{Icon.board()}</button>
136
+ </div>
137
+ <button className="icon-btn" onClick={() => downloadExport(`/api/projects/${project.id}/export`)} title="导出该项目所有任务的完整执行过程(含耗时与 token 统计,Markdown)" aria-label="导出项目执行过程">{Icon.download()}</button>
138
+ {!mobile && <button className="icon-btn" onClick={onCollapse} title="隐藏任务面板">{Icon.chevRight({ style: { transform: "rotate(180deg)" } })}</button>}
139
+ </div>
140
+ <button className="pb-ctx" onClick={onEditContext} title="编辑项目上下文">
141
+ <div className={`ctx-txt ${project.context ? "" : "empty-ctx"}`}>
142
+ {project.context || "添加项目上下文 —— 项目简介、技术栈与规范,会附加到每个任务前。"}
143
+ </div>
144
+ <div className="ctx-edit">{Icon.edit()} 上下文</div>
145
+ </button>
146
+ </div>
147
+ {canSearch && <SearchBar value={query} onChange={setQuery} placeholder="搜索任务…" />}
148
+ {loading ? (
149
+ // The list in state is still the previous project's — skeleton cards
150
+ // instead of a flash of the wrong tasks (or a false "No tasks yet").
151
+ <div className="scroll">
152
+ <div className="task-scroll">
153
+ {[0, 1, 2].map((i) => <TaskCardSkeleton key={i} i={i} />)}
154
+ </div>
155
+ </div>
156
+ ) : view === "board" ? (
157
+ <div className="board-wrap">
158
+ {noMatches && <div className="search-empty">没有匹配“{query.trim()}”的任务。</div>}
159
+ <TaskBoard
160
+ tasks={shown} suggested={shownSuggested} agents={agents} selTaskId={selTaskId}
161
+ running={running} blockedBy={blockedBy} canDrag={!q}
162
+ onSelect={onSelectTask} onEditTask={onEditTask} onMove={onMoveTask}
163
+ onStartSuggestion={onStartSuggestion} onAcceptSuggestion={onAcceptSuggestion} onDismissSuggestion={onDismissSuggestion}
164
+ />
165
+ </div>
166
+ ) : (
167
+ <div className="scroll">
168
+ <div className="task-scroll">
169
+ {tasks.length === 0 && <div className="empty" style={{ padding: "30px 16px" }}><div className="e-t">还没有任务</div><div className="e-s">创建一个任务来启动智能体会话。</div></div>}
170
+ {noMatches && <div className="search-empty">没有匹配“{query.trim()}”的任务。</div>}
171
+ <TaskGroup label="需要你处理" tasks={needsYou} agents={agents} selTaskId={selTaskId} running={running} blockedBy={blockedBy} onSelect={onSelectTask} accent />
172
+ <TaskGroup label="进行中" tasks={groups.a} agents={agents} selTaskId={selTaskId} running={running} blockedBy={blockedBy} onSelect={onSelectTask} />
173
+ <TaskGroup label="已搁置" tasks={groups.h} agents={agents} selTaskId={selTaskId} running={running} blockedBy={blockedBy} onSelect={onSelectTask} />
174
+ <TaskGroup label="未开始" tasks={groups.r} agents={agents} selTaskId={selTaskId} running={running} blockedBy={blockedBy} onSelect={onSelectTask} />
175
+ <TaskGroup label="已完成" tasks={groups.g} agents={agents} selTaskId={selTaskId} running={running} blockedBy={blockedBy} onSelect={onSelectTask} collapsible collapsed={doneCollapsed && !q} onToggle={toggleDone} />
176
+ <TaskGroup label="已取消" tasks={groups.x} agents={agents} selTaskId={selTaskId} running={running} blockedBy={blockedBy} onSelect={onSelectTask} collapsible collapsed={cancelledCollapsed && !q} onToggle={toggleCancelled} />
177
+ </div>
178
+ {shownSuggested.length > 0 && (
179
+ <div className="suggest">
180
+ <div className="suggest-h">{Icon.spark()} 智能体的建议<span className="sp">{shownSuggested.length}</span></div>
181
+ {shownSuggested.map((s) => (
182
+ <div key={s.id} className="sug">
183
+ <StatusDot status="not_started" />
184
+ <div className="sg-meta">
185
+ <div className="sg-name">{s.title}</div>
186
+ {s.description && <div className="sg-why">{s.description}</div>}
187
+ </div>
188
+ <button className="sug-dismiss" title="编辑标题与描述" onClick={() => onEditTask(s.id)}>{Icon.edit()}</button>
189
+ <button className="sug-add" title="加入任务列表,稍后再开始" onClick={() => onAcceptSuggestion(s.id)}>{Icon.plus()} 添加</button>
190
+ <button className="sug-btn" onClick={() => onStartSuggestion(s.id)}>{Icon.play()} 开始</button>
191
+ <button className="sug-dismiss" title="忽略" onClick={() => onDismissSuggestion(s.id)}>{Icon.x()}</button>
192
+ </div>
193
+ ))}
194
+ </div>
195
+ )}
196
+ </div>
197
+ )}
198
+ </div>
199
+ );
200
+ }
@@ -0,0 +1,143 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import "@xterm/xterm/css/xterm.css";
5
+ import type { Terminal as XTerm } from "@xterm/xterm";
6
+ import type { FitAddon as FitAddonType } from "@xterm/addon-fit";
7
+
8
+ const darkTheme = {
9
+ background: "#15140f", foreground: "#E8E2D4", cursor: "#E0A07F",
10
+ selectionBackground: "#3A2B22", black: "#15140f", brightBlack: "#5E594E",
11
+ red: "#E0685D", green: "#8FBE82", yellow: "#DCB458", blue: "#6FA8CF",
12
+ magenta: "#C58FC4", cyan: "#79B8B0", white: "#E8E2D4", brightWhite: "#FBFAF6",
13
+ };
14
+ const lightTheme = {
15
+ background: "#FBFAF6", foreground: "#26241F", cursor: "#C2603C",
16
+ selectionBackground: "#F0D9CE", black: "#26241F", brightBlack: "#928D80",
17
+ red: "#C0503C", green: "#5C8C5A", yellow: "#9A6E14", blue: "#3E7CA8",
18
+ magenta: "#9E5BA0", cyan: "#3E7CA8", white: "#605C52", brightWhite: "#26241F",
19
+ };
20
+
21
+ // Imperative handle the mobile terminal sheet uses to feed input (paste, Enter,
22
+ // Ctrl-C buttons) without owning the websocket itself.
23
+ export interface TermApi { send: (data: string) => void; }
24
+
25
+ export function TerminalView({ cwd, port, fontSize = 12.5, onReady }: { cwd: string; port?: number; fontSize?: number; onReady?: (api: TermApi) => void }) {
26
+ const hostRef = useRef<HTMLDivElement>(null);
27
+ const termRef = useRef<XTerm | null>(null);
28
+ const fitRef = useRef<FitAddonType | null>(null);
29
+ // Bumping this tears down the dead xterm/socket and spawns a fresh shell.
30
+ const [session, setSession] = useState(0);
31
+ // Read the latest font size at shell-creation time without making it an effect
32
+ // dep (which would respawn the shell on every zoom); live changes apply below.
33
+ const fontRef = useRef(fontSize);
34
+ fontRef.current = fontSize;
35
+
36
+ useEffect(() => {
37
+ let term: XTerm | null = null;
38
+ let fit: FitAddonType | null = null;
39
+ let ws: WebSocket | null = null;
40
+ let ro: ResizeObserver | null = null;
41
+ let disposed = false;
42
+ let dead = false; // shell gone (exit or sidecar drop) — awaiting Enter to respawn
43
+
44
+ (async () => {
45
+ const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
46
+ import("@xterm/xterm"),
47
+ import("@xterm/addon-fit"),
48
+ import("@xterm/addon-web-links"),
49
+ ]);
50
+ if (disposed || !hostRef.current) return;
51
+
52
+ const dark = document.documentElement.getAttribute("data-theme") === "dark";
53
+ term = new Terminal({
54
+ fontFamily: "'JetBrains Mono', ui-monospace, monospace",
55
+ fontSize: fontRef.current,
56
+ lineHeight: 1.25,
57
+ cursorBlink: true,
58
+ scrollback: 8000,
59
+ theme: dark ? darkTheme : lightTheme,
60
+ });
61
+ fit = new FitAddon();
62
+ term.loadAddon(fit);
63
+ // Tappable links — the whole point of the mobile terminal's login flow is
64
+ // opening the OAuth URL Claude prints, so route clicks/taps to a new tab.
65
+ term.loadAddon(new WebLinksAddon((_e, uri) => window.open(uri, "_blank", "noopener,noreferrer")));
66
+ term.open(hostRef.current);
67
+ termRef.current = term;
68
+ fitRef.current = fit;
69
+ try { fit.fit(); } catch {}
70
+
71
+ // The Next server proxies /pty to the local node-pty sidecar, so one
72
+ // hostname carries both (works behind a tunnel). PUBLIC_BASE_URL (injected
73
+ // by the layout) overrides the origin when the instance's public address
74
+ // differs from what the browser sees; empty = same origin as the app.
75
+ const baseUrl = (window as { __PUBLIC_BASE_URL?: string }).__PUBLIC_BASE_URL || window.location.origin;
76
+ const wsBase = baseUrl.replace(/^http/, "ws");
77
+ const portQ = port && port > 0 ? `&port=${port}` : "";
78
+ const url = `${wsBase}/pty?cwd=${encodeURIComponent(cwd)}&cols=${term.cols}&rows=${term.rows}${portQ}`;
79
+ ws = new WebSocket(url);
80
+ ws.binaryType = "arraybuffer";
81
+
82
+ ws.onmessage = (e) => {
83
+ if (typeof e.data === "string") {
84
+ try {
85
+ const m = JSON.parse(e.data);
86
+ if (m.type === "exit") term!.write(`\r\n\x1b[90m[进程已退出(${m.exitCode})]\x1b[0m\r\n`);
87
+ } catch {}
88
+ } else {
89
+ term!.write(new Uint8Array(e.data));
90
+ }
91
+ };
92
+ ws.onerror = () => { if (!disposed) term!.write(`\r\n\x1b[31m[terminal unreachable — is the pty-server sidecar running?]\x1b[0m\r\n`); };
93
+ ws.onclose = () => {
94
+ if (disposed) return;
95
+ dead = true;
96
+ term!.write("\r\n\x1b[90m[连接已断开 —— 按回车启动新的 shell]\x1b[0m\r\n");
97
+ };
98
+
99
+ // Single input path for both typed keystrokes and the mobile button-bar:
100
+ // when the shell is dead, Enter respawns it; otherwise forward to the pty.
101
+ const send = (d: string) => {
102
+ if (dead) {
103
+ if (d === "\r") setSession((s) => s + 1);
104
+ return;
105
+ }
106
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "input", data: d }));
107
+ };
108
+ term.onData(send);
109
+ onReady?.({ send });
110
+
111
+ const syncSize = () => {
112
+ if (!hostRef.current || hostRef.current.clientHeight < 24) return; // skip when collapsed
113
+ try {
114
+ fit!.fit();
115
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols: term!.cols, rows: term!.rows }));
116
+ } catch {}
117
+ };
118
+ ro = new ResizeObserver(syncSize);
119
+ ro.observe(hostRef.current);
120
+ term.focus();
121
+ })();
122
+
123
+ return () => {
124
+ disposed = true;
125
+ termRef.current = null;
126
+ fitRef.current = null;
127
+ try { ro?.disconnect(); } catch {}
128
+ try { ws?.close(); } catch {}
129
+ try { term?.dispose(); } catch {}
130
+ };
131
+ }, [cwd, session]);
132
+
133
+ // Live font-size changes (mobile A−/A+) without respawning the shell: retheme
134
+ // the existing terminal and refit so the pty's cols/rows track the new metrics.
135
+ useEffect(() => {
136
+ const t = termRef.current;
137
+ if (!t) return;
138
+ t.options.fontSize = fontSize;
139
+ try { fitRef.current?.fit(); } catch {}
140
+ }, [fontSize]);
141
+
142
+ return <div className="term-host" ref={hostRef} />;
143
+ }