helloloop 0.8.6 → 0.10.0

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 (52) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +230 -498
  4. package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
  5. package/hosts/gemini/extension/gemini-extension.json +1 -1
  6. package/native/windows-hidden-shell-proxy/HelloLoopHiddenShellProxy.csproj +11 -0
  7. package/native/windows-hidden-shell-proxy/Program.cs +498 -0
  8. package/package.json +4 -2
  9. package/src/activity_projection.mjs +294 -0
  10. package/src/analyze_confirmation.mjs +3 -1
  11. package/src/analyzer.mjs +2 -1
  12. package/src/auto_execution_options.mjs +13 -0
  13. package/src/background_launch.mjs +73 -0
  14. package/src/cli.mjs +51 -1
  15. package/src/cli_analyze_command.mjs +12 -14
  16. package/src/cli_args.mjs +106 -32
  17. package/src/cli_command_handlers.mjs +73 -25
  18. package/src/cli_support.mjs +2 -0
  19. package/src/common.mjs +11 -0
  20. package/src/dashboard_command.mjs +371 -0
  21. package/src/dashboard_tui.mjs +289 -0
  22. package/src/dashboard_web.mjs +351 -0
  23. package/src/dashboard_web_client.mjs +167 -0
  24. package/src/dashboard_web_page.mjs +49 -0
  25. package/src/engine_event_parser_codex.mjs +167 -0
  26. package/src/engine_process_support.mjs +7 -2
  27. package/src/engine_selection.mjs +24 -0
  28. package/src/engine_selection_probe.mjs +10 -6
  29. package/src/engine_selection_settings.mjs +53 -44
  30. package/src/execution_interactivity.mjs +12 -0
  31. package/src/host_continuation.mjs +305 -0
  32. package/src/install_codex.mjs +20 -30
  33. package/src/install_shared.mjs +9 -0
  34. package/src/node_process_launch.mjs +28 -0
  35. package/src/process.mjs +2 -0
  36. package/src/runner_execute_task.mjs +15 -1
  37. package/src/runner_execution_support.mjs +69 -3
  38. package/src/runner_once.mjs +5 -0
  39. package/src/runner_status.mjs +72 -4
  40. package/src/runtime_engine_support.mjs +52 -5
  41. package/src/runtime_engine_task.mjs +7 -0
  42. package/src/runtime_settings.mjs +105 -0
  43. package/src/runtime_settings_loader.mjs +19 -0
  44. package/src/shell_invocation.mjs +227 -9
  45. package/src/supervisor_cli_support.mjs +49 -0
  46. package/src/supervisor_guardian.mjs +307 -0
  47. package/src/supervisor_runtime.mjs +142 -83
  48. package/src/supervisor_state.mjs +64 -0
  49. package/src/supervisor_watch.mjs +364 -0
  50. package/src/terminal_session_limits.mjs +1 -21
  51. package/src/windows_hidden_shell_proxy.mjs +405 -0
  52. package/src/workspace_registry.mjs +155 -0
@@ -0,0 +1,371 @@
1
+ import path from "node:path";
2
+
3
+ import { nowIso, sleep } from "./common.mjs";
4
+ import { createContext } from "./context.mjs";
5
+ import { analyzeExecution } from "./backlog.mjs";
6
+ import { loadBacklog } from "./config.mjs";
7
+ import { buildDashboardHostContinuation } from "./host_continuation.mjs";
8
+ import { loadRuntimeSettings } from "./runtime_settings_loader.mjs";
9
+ import { collectRepoStatusSnapshot } from "./runner_status.mjs";
10
+ import { hasRetryBudget, pickRetryDelaySeconds } from "./runtime_settings.mjs";
11
+ import { isTrackedPidAlive } from "./supervisor_state.mjs";
12
+ import { listActiveSessionEntries, listKnownWorkspaceEntries } from "./workspace_registry.mjs";
13
+
14
+ function formatRuntimeLabel(runtime) {
15
+ if (!runtime?.status) {
16
+ return "idle";
17
+ }
18
+ const details = [runtime.status];
19
+ if (Number.isFinite(Number(runtime.recoveryCount)) && Number(runtime.recoveryCount) > 0) {
20
+ details.push(`recovery=${runtime.recoveryCount}`);
21
+ }
22
+ if (Number.isFinite(Number(runtime?.heartbeat?.idleSeconds)) && Number(runtime.heartbeat.idleSeconds) > 0) {
23
+ details.push(`idle=${runtime.heartbeat.idleSeconds}s`);
24
+ }
25
+ return details.join(" | ");
26
+ }
27
+
28
+ function formatCurrentAction(session) {
29
+ return session.activity?.current?.label
30
+ || session.runtime?.failureReason
31
+ || session.latestStatus?.message
32
+ || session.runtime?.status
33
+ || "等待新事件";
34
+ }
35
+
36
+ function formatTodoLabel(activity) {
37
+ if (!activity?.todo?.total) {
38
+ return "";
39
+ }
40
+ return `${activity.todo.completed}/${activity.todo.total}`;
41
+ }
42
+
43
+ function readBacklogSnapshot(context) {
44
+ try {
45
+ const backlog = loadBacklog(context);
46
+ return {
47
+ tasks: Array.isArray(backlog.tasks) ? backlog.tasks : [],
48
+ execution: analyzeExecution(backlog),
49
+ };
50
+ } catch {
51
+ return {
52
+ tasks: [],
53
+ execution: null,
54
+ };
55
+ }
56
+ }
57
+
58
+ function normalizeSupervisorSnapshot(supervisor) {
59
+ if (!supervisor?.status) {
60
+ return supervisor;
61
+ }
62
+ if (["launching", "running"].includes(String(supervisor.status)) && !isTrackedPidAlive(supervisor.guardianPid || supervisor.pid)) {
63
+ return {
64
+ ...supervisor,
65
+ status: "stopped",
66
+ message: supervisor.message || "后台会话当前未运行。",
67
+ };
68
+ }
69
+ return supervisor;
70
+ }
71
+
72
+ function normalizeRuntimeSnapshot(supervisor, runtime) {
73
+ if (!runtime) {
74
+ return runtime;
75
+ }
76
+ if (!supervisor?.status || supervisor.status !== "stopped") {
77
+ return runtime;
78
+ }
79
+ if (!["launching", "running", "recovering", "probe_running", "probe_waiting", "retry_waiting"].includes(String(runtime.status || ""))) {
80
+ return runtime;
81
+ }
82
+
83
+ const stoppedReason = "后台 supervisor 当前未运行;以下展示的是最近一次执行快照。";
84
+ return {
85
+ ...runtime,
86
+ status: "stopped",
87
+ failureReason: runtime.failureReason || stoppedReason,
88
+ heartbeat: runtime.heartbeat
89
+ ? {
90
+ ...runtime.heartbeat,
91
+ status: "stopped",
92
+ leaseExpired: true,
93
+ leaseReason: runtime.heartbeat.leaseReason || stoppedReason,
94
+ }
95
+ : runtime.heartbeat,
96
+ };
97
+ }
98
+
99
+ function normalizeActivitySnapshot(supervisor, activity) {
100
+ if (!activity) {
101
+ return activity;
102
+ }
103
+ if (!supervisor?.status || supervisor.status !== "stopped") {
104
+ return activity;
105
+ }
106
+ if (String(activity.status || "") !== "running") {
107
+ return activity;
108
+ }
109
+
110
+ return {
111
+ ...activity,
112
+ status: "stopped",
113
+ runtime: activity.runtime
114
+ ? {
115
+ ...activity.runtime,
116
+ status: "stopped",
117
+ }
118
+ : activity.runtime,
119
+ };
120
+ }
121
+
122
+ function buildSessionSnapshot(entry) {
123
+ const context = createContext({
124
+ repoRoot: entry.repoRoot,
125
+ configDirName: entry.configDirName,
126
+ });
127
+ let repoSnapshot;
128
+ try {
129
+ repoSnapshot = collectRepoStatusSnapshot(context, {
130
+ sessionId: entry.sessionId,
131
+ });
132
+ } catch (error) {
133
+ repoSnapshot = {
134
+ supervisor: null,
135
+ latestStatus: {
136
+ message: String(error?.message || error || "状态读取失败。"),
137
+ },
138
+ runtime: null,
139
+ activity: null,
140
+ summary: null,
141
+ nextTask: null,
142
+ };
143
+ }
144
+ const backlogSnapshot = readBacklogSnapshot(context);
145
+ const supervisor = normalizeSupervisorSnapshot(repoSnapshot.supervisor);
146
+ const runtime = normalizeRuntimeSnapshot(supervisor, repoSnapshot.runtime);
147
+ const activity = normalizeActivitySnapshot(supervisor, repoSnapshot.activity);
148
+ const snapshotForContinuation = {
149
+ ...repoSnapshot,
150
+ supervisor,
151
+ runtime,
152
+ activity,
153
+ };
154
+
155
+ return {
156
+ repoRoot: entry.repoRoot,
157
+ repoName: path.basename(entry.repoRoot),
158
+ sessionId: entry.sessionId || repoSnapshot.latestStatus?.sessionId || "",
159
+ command: entry.command || supervisor?.command || "",
160
+ supervisor,
161
+ latestStatus: repoSnapshot.latestStatus,
162
+ runtime,
163
+ activity,
164
+ summary: repoSnapshot.summary,
165
+ nextTask: repoSnapshot.nextTask,
166
+ tasks: backlogSnapshot.tasks,
167
+ execution: backlogSnapshot.execution,
168
+ isActive: entry.isActive === true,
169
+ hostResume: buildDashboardHostContinuation(entry, snapshotForContinuation),
170
+ updatedAt: repoSnapshot.activity?.updatedAt
171
+ || repoSnapshot.runtime?.updatedAt
172
+ || repoSnapshot.latestStatus?.updatedAt
173
+ || supervisor?.updatedAt
174
+ || entry.updatedAt
175
+ || "",
176
+ };
177
+ }
178
+
179
+ function buildTaskTotals(sessions) {
180
+ const totals = {
181
+ total: 0,
182
+ pending: 0,
183
+ inProgress: 0,
184
+ done: 0,
185
+ failed: 0,
186
+ blocked: 0,
187
+ };
188
+
189
+ for (const session of sessions) {
190
+ totals.total += Number(session.summary?.total || 0);
191
+ totals.pending += Number(session.summary?.pending || 0);
192
+ totals.inProgress += Number(session.summary?.inProgress || 0);
193
+ totals.done += Number(session.summary?.done || 0);
194
+ totals.failed += Number(session.summary?.failed || 0);
195
+ totals.blocked += Number(session.summary?.blocked || 0);
196
+ }
197
+
198
+ return totals;
199
+ }
200
+
201
+ export function collectDashboardSnapshot() {
202
+ const activeEntries = listActiveSessionEntries()
203
+ .map((entry) => ({ ...entry, isActive: true }));
204
+ const knownEntries = listKnownWorkspaceEntries()
205
+ .map((entry) => ({ ...entry, isActive: false }));
206
+ const mergedEntries = new Map();
207
+
208
+ for (const entry of [...knownEntries, ...activeEntries]) {
209
+ const key = `${entry.repoRoot}::${entry.configDirName || ""}`;
210
+ const current = mergedEntries.get(key) || {};
211
+ mergedEntries.set(key, {
212
+ ...current,
213
+ ...entry,
214
+ isActive: entry.isActive === true || current.isActive === true,
215
+ });
216
+ }
217
+
218
+ const sessions = [...mergedEntries.values()]
219
+ .map((entry) => buildSessionSnapshot(entry))
220
+ .sort((left, right) => String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")));
221
+
222
+ return {
223
+ schemaVersion: 1,
224
+ generatedAt: nowIso(),
225
+ activeCount: activeEntries.length,
226
+ repoCount: sessions.length,
227
+ taskTotals: buildTaskTotals(sessions),
228
+ primaryHostResume: sessions[0]?.hostResume || null,
229
+ sessions,
230
+ };
231
+ }
232
+
233
+ function renderCompactSession(session, index) {
234
+ return [
235
+ `${index + 1}. ${session.repoName}`,
236
+ ` session=${session.sessionId}`,
237
+ ` task=${session.latestStatus?.taskTitle || "无"}`,
238
+ ` runtime=${formatRuntimeLabel(session.runtime)}`,
239
+ ` action=${formatCurrentAction(session)}`,
240
+ ...(formatTodoLabel(session.activity) ? [` todo=${formatTodoLabel(session.activity)}`] : []),
241
+ ].join("\n");
242
+ }
243
+
244
+ function renderDetailedSession(session, index, options) {
245
+ const lines = [
246
+ `[${index + 1}] ${session.repoName}`,
247
+ `- 仓库:${session.repoRoot}`,
248
+ `- 会话:${session.sessionId}`,
249
+ `- supervisor:${session.supervisor?.status || "unknown"}`,
250
+ `- 命令:${session.command || "unknown"}`,
251
+ `- 当前任务:${session.latestStatus?.taskTitle || "无"}`,
252
+ `- 当前阶段:${session.latestStatus?.stage || "unknown"}`,
253
+ `- backlog:已完成 ${session.summary?.done || 0} / 总计 ${session.summary?.total || 0} / 待处理 ${session.summary?.pending || 0}`,
254
+ `- 运行状态:${formatRuntimeLabel(session.runtime)}`,
255
+ `- 当前动作:${formatCurrentAction(session)}`,
256
+ `- 宿主续跑:${session.hostResume?.issue?.label || (session.hostResume?.supervisorActive ? "后台仍在运行,可直接接续观察" : "需要按续跑提示继续")}`,
257
+ ];
258
+
259
+ if (formatTodoLabel(session.activity)) {
260
+ lines.push(`- 当前待办:${formatTodoLabel(session.activity)}`);
261
+ }
262
+ if (Array.isArray(session.activity?.activeCommands) && session.activity.activeCommands[0]?.label) {
263
+ lines.push(`- 活动命令:${session.activity.activeCommands[0].label}`);
264
+ }
265
+ if (Array.isArray(session.activity?.recentFileChanges) && session.activity.recentFileChanges[0]?.changes?.length) {
266
+ const fileLabels = session.activity.recentFileChanges[0].changes
267
+ .slice(0, 3)
268
+ .map((item) => `${item.kind}:${item.path}`);
269
+ lines.push(`- 最近文件:${fileLabels.join(" | ")}`);
270
+ }
271
+ if (options.events && Array.isArray(session.activity?.recentEvents) && session.activity.recentEvents.length) {
272
+ lines.push("- 最近事件:");
273
+ for (const event of session.activity.recentEvents.slice(-5)) {
274
+ lines.push(` - [${event.status || "info"}] ${event.kind}: ${event.label}`);
275
+ }
276
+ }
277
+
278
+ return lines.join("\n");
279
+ }
280
+
281
+ export function renderDashboardText(snapshot, options = {}) {
282
+ const lines = [
283
+ "HelloLoop Dashboard",
284
+ "===================",
285
+ `仓库总数:${snapshot.repoCount}`,
286
+ `活跃会话:${snapshot.activeCount}`,
287
+ `更新时间:${snapshot.generatedAt}`,
288
+ ];
289
+
290
+ if (!snapshot.sessions.length) {
291
+ lines.push("");
292
+ lines.push("当前没有已登记仓库或后台会话。");
293
+ return lines.join("\n");
294
+ }
295
+
296
+ for (const [index, session] of snapshot.sessions.entries()) {
297
+ lines.push("");
298
+ lines.push(options.compact
299
+ ? renderCompactSession(session, index)
300
+ : renderDetailedSession(session, index, options));
301
+ }
302
+
303
+ return lines.join("\n");
304
+ }
305
+
306
+ export function buildDashboardSnapshotSignature(snapshot) {
307
+ return JSON.stringify(snapshot.sessions.map((session) => ({
308
+ repoRoot: session.repoRoot,
309
+ sessionId: session.sessionId,
310
+ taskId: session.latestStatus?.taskId || "",
311
+ stage: session.latestStatus?.stage || "",
312
+ runtimeStatus: session.runtime?.status || "",
313
+ idleSeconds: session.runtime?.heartbeat?.idleSeconds || 0,
314
+ action: session.activity?.current?.label || "",
315
+ todoCompleted: session.activity?.todo?.completed || 0,
316
+ todoTotal: session.activity?.todo?.total || 0,
317
+ eventLabel: session.activity?.recentEvents?.at(-1)?.label || "",
318
+ resumeIssue: session.hostResume?.issue?.code || "",
319
+ taskFingerprint: (session.tasks || [])
320
+ .map((task) => `${task.id}:${task.status || "pending"}:${task.title}`)
321
+ .join("|"),
322
+ })));
323
+ }
324
+
325
+ export async function runDashboardCommand(options = {}) {
326
+ const pollMs = Math.max(500, Number(options.pollMs || options.watchPollMs || 2000));
327
+ const observerRetry = loadRuntimeSettings({
328
+ globalConfigFile: options.globalConfigFile,
329
+ }).observerRetry;
330
+ let previousSignature = "";
331
+ let retryCount = 0;
332
+
333
+ while (true) {
334
+ try {
335
+ const snapshot = collectDashboardSnapshot();
336
+ const signature = buildDashboardSnapshotSignature(snapshot);
337
+ retryCount = 0;
338
+
339
+ if (signature !== previousSignature || !options.watch) {
340
+ previousSignature = signature;
341
+ if (options.json) {
342
+ console.log(JSON.stringify(snapshot));
343
+ } else {
344
+ if (options.watch && process.stdout.isTTY) {
345
+ process.stdout.write("\x1bc");
346
+ }
347
+ console.log(renderDashboardText(snapshot, options));
348
+ }
349
+ }
350
+ } catch (error) {
351
+ const nextAttempt = retryCount + 1;
352
+ if (!options.watch || !observerRetry.enabled || !hasRetryBudget(observerRetry.maxRetryCount, nextAttempt)) {
353
+ throw error;
354
+ }
355
+
356
+ const delaySeconds = pickRetryDelaySeconds(observerRetry.retryDelaysSeconds, nextAttempt);
357
+ process.stderr.write(
358
+ `[HelloLoop dashboard] 看板采集失败,将在 ${delaySeconds} 秒后自动重试(第 ${nextAttempt} 次):${String(error?.message || error || "unknown error")}\n`,
359
+ );
360
+ retryCount = nextAttempt;
361
+ await sleep(delaySeconds * 1000);
362
+ continue;
363
+ }
364
+
365
+ if (!options.watch) {
366
+ return 0;
367
+ }
368
+
369
+ await sleep(pollMs);
370
+ }
371
+ }
@@ -0,0 +1,289 @@
1
+ import { collectDashboardSnapshot } from "./dashboard_command.mjs";
2
+ import { sleep } from "./common.mjs";
3
+ import { loadRuntimeSettings } from "./runtime_settings_loader.mjs";
4
+ import { hasRetryBudget, pickRetryDelaySeconds } from "./runtime_settings.mjs";
5
+
6
+ const STATUS_GROUPS = [
7
+ { key: "in_progress", label: "进行中" },
8
+ { key: "pending", label: "待处理" },
9
+ { key: "done", label: "已完成" },
10
+ { key: "blocked", label: "阻塞" },
11
+ { key: "failed", label: "失败" },
12
+ ];
13
+
14
+ const KEYBOARD_HELP = [
15
+ "←/→ 切换仓库",
16
+ "↑/↓ 滚动",
17
+ "1-9 直达仓库",
18
+ "r 刷新",
19
+ "q 退出",
20
+ ];
21
+
22
+ function truncateText(text, maxWidth) {
23
+ if (maxWidth <= 0) {
24
+ return "";
25
+ }
26
+ const normalized = String(text || "").replace(/\s+/gu, " ").trim();
27
+ if (normalized.length <= maxWidth) {
28
+ return normalized.padEnd(maxWidth, " ");
29
+ }
30
+ if (maxWidth <= 1) {
31
+ return normalized.slice(0, maxWidth);
32
+ }
33
+ return `${normalized.slice(0, Math.max(0, maxWidth - 1))}…`;
34
+ }
35
+
36
+ function repeat(char, count) {
37
+ return count > 0 ? char.repeat(count) : "";
38
+ }
39
+
40
+ function normalizeSelectedIndex(snapshot, currentIndex) {
41
+ if (!snapshot.sessions.length) {
42
+ return 0;
43
+ }
44
+ const bounded = Number(currentIndex || 0);
45
+ if (!Number.isFinite(bounded) || bounded < 0) {
46
+ return 0;
47
+ }
48
+ return Math.min(bounded, snapshot.sessions.length - 1);
49
+ }
50
+
51
+ function groupTasks(session) {
52
+ const grouped = new Map(STATUS_GROUPS.map((item) => [item.key, []]));
53
+ for (const task of Array.isArray(session?.tasks) ? session.tasks : []) {
54
+ const status = String(task.status || "pending");
55
+ const bucket = grouped.get(status) || grouped.get("pending");
56
+ bucket.push(task);
57
+ }
58
+ return grouped;
59
+ }
60
+
61
+ function renderRepoTabs(snapshot, selectedIndex, width) {
62
+ const tabs = snapshot.sessions.map((session, index) => {
63
+ const active = index === selectedIndex ? "*" : " ";
64
+ const task = session.latestStatus?.taskTitle || session.nextTask?.title || "无任务";
65
+ return `${active}${index + 1}.${session.repoName} ${session.runtime?.status || "idle"} ${task}`;
66
+ });
67
+
68
+ const line = tabs.join(" | ");
69
+ return truncateText(line, width);
70
+ }
71
+
72
+ function buildSessionMetaLines(session, width) {
73
+ const lines = [
74
+ `仓库:${session.repoRoot}`,
75
+ `会话:${session.sessionId}`,
76
+ `当前任务:${session.latestStatus?.taskTitle || "无"}`,
77
+ `运行状态:${session.runtime?.status || "idle"}${Number.isFinite(Number(session.runtime?.heartbeat?.idleSeconds)) && Number(session.runtime?.heartbeat?.idleSeconds) > 0 ? ` | idle=${session.runtime.heartbeat.idleSeconds}s` : ""}`,
78
+ `当前动作:${session.activity?.current?.label || session.latestStatus?.message || session.runtime?.failureReason || "等待新事件"}`,
79
+ `backlog:总计 ${session.summary?.total || 0} / 待处理 ${session.summary?.pending || 0} / 进行中 ${session.summary?.inProgress || 0} / 已完成 ${session.summary?.done || 0} / 阻塞 ${session.summary?.blocked || 0} / 失败 ${session.summary?.failed || 0}`,
80
+ ];
81
+
82
+ return lines.map((line) => truncateText(line, width));
83
+ }
84
+
85
+ function renderTaskLine(task, width) {
86
+ const meta = `[${task.priority || "P2"}|${task.risk || "low"}]`;
87
+ return truncateText(`${meta} ${task.title}`, width);
88
+ }
89
+
90
+ function renderTaskSections(session, width) {
91
+ const sections = [];
92
+ const grouped = groupTasks(session);
93
+
94
+ for (const statusGroup of STATUS_GROUPS) {
95
+ const tasks = grouped.get(statusGroup.key) || [];
96
+ sections.push(`${statusGroup.label} (${tasks.length})`);
97
+ if (!tasks.length) {
98
+ sections.push(" - 无");
99
+ sections.push("");
100
+ continue;
101
+ }
102
+
103
+ for (const task of tasks) {
104
+ sections.push(` - ${renderTaskLine(task, Math.max(10, width - 4))}`);
105
+ }
106
+ sections.push("");
107
+ }
108
+
109
+ return sections;
110
+ }
111
+
112
+ function renderEmptyState(width, height, message) {
113
+ const lines = [
114
+ "HelloLoop TUI Dashboard",
115
+ repeat("=", Math.max(20, Math.min(width, 40))),
116
+ "",
117
+ message,
118
+ ];
119
+ while (lines.length < height) {
120
+ lines.push("");
121
+ }
122
+ return lines.slice(0, height).map((line) => truncateText(line, width)).join("\n");
123
+ }
124
+
125
+ function renderTui(snapshot, state) {
126
+ const width = Math.max(60, Number(process.stdout.columns || 120));
127
+ const height = Math.max(24, Number(process.stdout.rows || 40));
128
+
129
+ if (!snapshot.sessions.length) {
130
+ return renderEmptyState(width, height, "当前没有已登记仓库或后台会话。");
131
+ }
132
+
133
+ const selectedIndex = normalizeSelectedIndex(snapshot, state.selectedIndex);
134
+ const selectedSession = snapshot.sessions[selectedIndex];
135
+ const lines = [
136
+ truncateText(`HelloLoop TUI Dashboard | 仓库 ${snapshot.repoCount || 0} | 活跃会话 ${snapshot.activeCount || 0} | 任务总计 ${snapshot.taskTotals?.total || 0} | 待处理 ${snapshot.taskTotals?.pending || 0} | 进行中 ${snapshot.taskTotals?.inProgress || 0} | 已完成 ${snapshot.taskTotals?.done || 0}`, width),
137
+ truncateText(`更新时间:${snapshot.generatedAt} | 键位:${KEYBOARD_HELP.join(" / ")}`, width),
138
+ repeat("─", width),
139
+ renderRepoTabs(snapshot, selectedIndex, width),
140
+ repeat("─", width),
141
+ ...buildSessionMetaLines(selectedSession, width),
142
+ repeat("─", width),
143
+ ];
144
+
145
+ const contentLines = renderTaskSections(selectedSession, width);
146
+ const availableContentHeight = Math.max(1, height - lines.length - 1);
147
+ const maxOffset = Math.max(0, contentLines.length - availableContentHeight);
148
+ const scrollOffset = Math.min(Math.max(0, Number(state.scrollOffset || 0)), maxOffset);
149
+
150
+ const visibleContent = contentLines.slice(scrollOffset, scrollOffset + availableContentHeight);
151
+ const footer = truncateText(
152
+ `仓库 ${selectedIndex + 1}/${snapshot.sessions.length} | 滚动 ${scrollOffset + 1}-${Math.min(contentLines.length, scrollOffset + visibleContent.length)}/${contentLines.length}`,
153
+ width,
154
+ );
155
+
156
+ while (visibleContent.length < availableContentHeight) {
157
+ visibleContent.push("");
158
+ }
159
+
160
+ return [...lines, ...visibleContent, footer]
161
+ .slice(0, height)
162
+ .map((line) => truncateText(line, width))
163
+ .join("\n");
164
+ }
165
+
166
+ function applyInputKey(buffer, state, snapshot) {
167
+ const input = String(buffer || "");
168
+ if (!input) {
169
+ return false;
170
+ }
171
+
172
+ if (input === "\u0003" || input === "q") {
173
+ state.running = false;
174
+ return true;
175
+ }
176
+ if (input === "r") {
177
+ state.forceRefresh = true;
178
+ return true;
179
+ }
180
+ if (input === "\u001b[C") {
181
+ state.selectedIndex = Math.min(snapshot.sessions.length - 1, state.selectedIndex + 1);
182
+ state.scrollOffset = 0;
183
+ return true;
184
+ }
185
+ if (input === "\u001b[D") {
186
+ state.selectedIndex = Math.max(0, state.selectedIndex - 1);
187
+ state.scrollOffset = 0;
188
+ return true;
189
+ }
190
+ if (input === "\u001b[A") {
191
+ state.scrollOffset = Math.max(0, state.scrollOffset - 1);
192
+ return true;
193
+ }
194
+ if (input === "\u001b[B") {
195
+ state.scrollOffset += 1;
196
+ return true;
197
+ }
198
+
199
+ const digit = Number.parseInt(input, 10);
200
+ if (Number.isInteger(digit) && digit > 0 && digit <= snapshot.sessions.length) {
201
+ state.selectedIndex = digit - 1;
202
+ state.scrollOffset = 0;
203
+ return true;
204
+ }
205
+
206
+ return false;
207
+ }
208
+
209
+ function enterAlternateScreen() {
210
+ process.stdout.write("\x1b[?1049h\x1b[?25l");
211
+ }
212
+
213
+ function leaveAlternateScreen() {
214
+ process.stdout.write("\x1b[?25h\x1b[?1049l");
215
+ }
216
+
217
+ export async function runDashboardTuiCommand(options = {}) {
218
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
219
+ throw new Error("HelloLoop TUI 需要在真实终端中运行。");
220
+ }
221
+
222
+ const pollMs = Math.max(500, Number(options.pollMs || options.watchPollMs || 1500));
223
+ const observerRetry = loadRuntimeSettings({
224
+ globalConfigFile: options.globalConfigFile,
225
+ }).observerRetry;
226
+
227
+ const state = {
228
+ running: true,
229
+ selectedIndex: 0,
230
+ scrollOffset: 0,
231
+ forceRefresh: true,
232
+ };
233
+ let lastFrame = "";
234
+ let retryCount = 0;
235
+ let lastSnapshot = collectDashboardSnapshot();
236
+
237
+ const onData = (buffer) => {
238
+ applyInputKey(buffer, state, lastSnapshot);
239
+ };
240
+
241
+ enterAlternateScreen();
242
+ process.stdin.setEncoding("utf8");
243
+ process.stdin.setRawMode(true);
244
+ process.stdin.resume();
245
+ process.stdin.on("data", onData);
246
+
247
+ try {
248
+ while (state.running) {
249
+ try {
250
+ lastSnapshot = collectDashboardSnapshot();
251
+ retryCount = 0;
252
+ } catch (error) {
253
+ const nextAttempt = retryCount + 1;
254
+ if (!observerRetry.enabled || !hasRetryBudget(observerRetry.maxRetryCount, nextAttempt)) {
255
+ throw error;
256
+ }
257
+
258
+ const delaySeconds = pickRetryDelaySeconds(observerRetry.retryDelaysSeconds, nextAttempt);
259
+ const frame = renderEmptyState(
260
+ Math.max(60, Number(process.stdout.columns || 120)),
261
+ Math.max(24, Number(process.stdout.rows || 40)),
262
+ `看板采集失败,将在 ${delaySeconds} 秒后自动重试(第 ${nextAttempt} 次):${String(error?.message || error || "unknown error")}`,
263
+ );
264
+ process.stdout.write("\x1b[H\x1b[2J");
265
+ process.stdout.write(frame);
266
+ retryCount = nextAttempt;
267
+ await sleep(delaySeconds * 1000);
268
+ continue;
269
+ }
270
+
271
+ const frame = renderTui(lastSnapshot, state);
272
+ if (frame !== lastFrame || state.forceRefresh) {
273
+ process.stdout.write("\x1b[H\x1b[2J");
274
+ process.stdout.write(frame);
275
+ lastFrame = frame;
276
+ state.forceRefresh = false;
277
+ }
278
+
279
+ await sleep(pollMs);
280
+ }
281
+ } finally {
282
+ process.stdin.off("data", onData);
283
+ process.stdin.setRawMode(false);
284
+ process.stdin.pause();
285
+ leaveAlternateScreen();
286
+ }
287
+
288
+ return 0;
289
+ }