@co0ontty/wand 1.18.12 → 1.20.4
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.
- package/dist/cli.js +72 -5
- package/dist/ensure-node-pty-helper.d.ts +1 -0
- package/dist/ensure-node-pty-helper.js +51 -0
- package/dist/git-quick-commit.d.ts +18 -0
- package/dist/git-quick-commit.js +373 -0
- package/dist/process-manager.d.ts +4 -8
- package/dist/process-manager.js +12 -161
- package/dist/prompt-optimizer.d.ts +5 -0
- package/dist/prompt-optimizer.js +72 -0
- package/dist/server-session-routes.d.ts +2 -2
- package/dist/server-session-routes.js +73 -1
- package/dist/server.d.ts +19 -1
- package/dist/server.js +90 -5
- package/dist/tui/index.d.ts +24 -0
- package/dist/tui/index.js +138 -0
- package/dist/tui/layout.d.ts +25 -0
- package/dist/tui/layout.js +198 -0
- package/dist/tui/log-bus.d.ts +23 -0
- package/dist/tui/log-bus.js +111 -0
- package/dist/tui/relative-time.d.ts +4 -0
- package/dist/tui/relative-time.js +27 -0
- package/dist/tui/session-formatter.d.ts +17 -0
- package/dist/tui/session-formatter.js +111 -0
- package/dist/types.d.ts +42 -0
- package/dist/web-ui/content/scripts.js +749 -141
- package/dist/web-ui/content/styles.css +334 -3
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { buildLayout } from "./layout.js";
|
|
2
|
+
import { installLogBus, restoreLogBus } from "./log-bus.js";
|
|
3
|
+
import { formatSession, sortRows } from "./session-formatter.js";
|
|
4
|
+
/** 触发 sessions 列表重渲的事件类型。output 太频繁,不订阅。 */
|
|
5
|
+
const SESSIONS_REFRESH_EVENTS = new Set(["status", "started", "ended", "task"]);
|
|
6
|
+
const RELATIVE_TIME_TICK_MS = 5_000;
|
|
7
|
+
export function startTui(deps) {
|
|
8
|
+
const layout = buildLayout();
|
|
9
|
+
let active = true;
|
|
10
|
+
let stopping = false;
|
|
11
|
+
const handle = {
|
|
12
|
+
get isActive() { return active; },
|
|
13
|
+
stop,
|
|
14
|
+
};
|
|
15
|
+
// 让 server 端的 wandError/wandWarn 能感知 TUI 在线。
|
|
16
|
+
globalThis.__wandTui = handle;
|
|
17
|
+
installLogBus((record) => {
|
|
18
|
+
if (!active)
|
|
19
|
+
return;
|
|
20
|
+
layout.appendLog(record);
|
|
21
|
+
});
|
|
22
|
+
const headerInfo = () => {
|
|
23
|
+
const all = collectSessions();
|
|
24
|
+
const counts = countSessions(all);
|
|
25
|
+
const primary = deps.urls[0];
|
|
26
|
+
return {
|
|
27
|
+
version: deps.version,
|
|
28
|
+
url: primary ? primary.url : `${deps.httpsEnabled ? "https" : "http"}://${deps.bindAddr}`,
|
|
29
|
+
scheme: primary ? primary.scheme : (deps.httpsEnabled ? "HTTPS" : "HTTP"),
|
|
30
|
+
bindAddr: deps.bindAddr,
|
|
31
|
+
configPath: deps.configPath,
|
|
32
|
+
dbPath: deps.dbPath,
|
|
33
|
+
orphanRecoveredCount: deps.orphanRecoveredCount,
|
|
34
|
+
sessionCounts: counts,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
function collectSessions() {
|
|
38
|
+
const ptyList = deps.processManager.listSlim();
|
|
39
|
+
const structuredList = safeStructuredList(deps.structuredSessions);
|
|
40
|
+
// 同 id 去重,PTY 优先(PM 是主路径)
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const merged = [];
|
|
43
|
+
for (const s of ptyList) {
|
|
44
|
+
seen.add(s.id);
|
|
45
|
+
merged.push(s);
|
|
46
|
+
}
|
|
47
|
+
for (const s of structuredList) {
|
|
48
|
+
if (!seen.has(s.id))
|
|
49
|
+
merged.push(s);
|
|
50
|
+
}
|
|
51
|
+
return sortRows(merged);
|
|
52
|
+
}
|
|
53
|
+
function countSessions(all) {
|
|
54
|
+
let act = 0, arch = 0;
|
|
55
|
+
for (const s of all) {
|
|
56
|
+
if (s.archived)
|
|
57
|
+
arch += 1;
|
|
58
|
+
else if (s.status === "running")
|
|
59
|
+
act += 1;
|
|
60
|
+
}
|
|
61
|
+
return { active: act, archived: arch, total: all.length };
|
|
62
|
+
}
|
|
63
|
+
function refreshAll() {
|
|
64
|
+
if (!active)
|
|
65
|
+
return;
|
|
66
|
+
const all = collectSessions();
|
|
67
|
+
layout.refreshHeader(headerInfo());
|
|
68
|
+
layout.refreshSessions(all.map((s) => formatSession(s)));
|
|
69
|
+
}
|
|
70
|
+
// —— 数据订阅 ——
|
|
71
|
+
const onPmEvent = (ev) => {
|
|
72
|
+
if (!active)
|
|
73
|
+
return;
|
|
74
|
+
if (SESSIONS_REFRESH_EVENTS.has(ev.type)) {
|
|
75
|
+
scheduleSessionsRefresh();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
deps.processManager.on("process", onPmEvent);
|
|
79
|
+
// structuredSessions 当前没有公开 EventEmitter 接口;本版本仅靠定时刷新覆盖其变化。
|
|
80
|
+
// 若后续暴露 .on("process", ...) 可以在此追加监听。
|
|
81
|
+
let pendingRefresh = false;
|
|
82
|
+
function scheduleSessionsRefresh() {
|
|
83
|
+
if (pendingRefresh)
|
|
84
|
+
return;
|
|
85
|
+
pendingRefresh = true;
|
|
86
|
+
setImmediate(() => {
|
|
87
|
+
pendingRefresh = false;
|
|
88
|
+
if (active)
|
|
89
|
+
refreshAll();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const tickTimer = setInterval(() => {
|
|
93
|
+
if (active)
|
|
94
|
+
refreshAll();
|
|
95
|
+
}, RELATIVE_TIME_TICK_MS);
|
|
96
|
+
tickTimer.unref?.();
|
|
97
|
+
// —— 键位 ——
|
|
98
|
+
layout.screen.key(["q", "Q"], () => { void stop("user"); });
|
|
99
|
+
layout.screen.key(["C-c"], () => { void stop("user"); });
|
|
100
|
+
layout.screen.key(["r", "R"], () => { refreshAll(); });
|
|
101
|
+
// 首次渲染
|
|
102
|
+
refreshAll();
|
|
103
|
+
async function stop(reason) {
|
|
104
|
+
if (stopping || !active)
|
|
105
|
+
return;
|
|
106
|
+
stopping = true;
|
|
107
|
+
active = false;
|
|
108
|
+
clearInterval(tickTimer);
|
|
109
|
+
try {
|
|
110
|
+
deps.processManager.off("process", onPmEvent);
|
|
111
|
+
}
|
|
112
|
+
catch { /* noop */ }
|
|
113
|
+
try {
|
|
114
|
+
layout.destroy();
|
|
115
|
+
}
|
|
116
|
+
catch { /* destroyed */ }
|
|
117
|
+
restoreLogBus();
|
|
118
|
+
if (globalThis.__wandTui === handle) {
|
|
119
|
+
delete globalThis.__wandTui;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await deps.onExit(reason);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
// 此时 stderr 已经还原,安全打印。
|
|
126
|
+
process.stderr.write(`[wand] TUI 退出回调失败: ${String(err)}\n`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return handle;
|
|
130
|
+
}
|
|
131
|
+
function safeStructuredList(mgr) {
|
|
132
|
+
try {
|
|
133
|
+
return mgr.listSlim();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { LogRecord } from "./log-bus.js";
|
|
2
|
+
import { SessionRow } from "./session-formatter.js";
|
|
3
|
+
export interface HeaderInfo {
|
|
4
|
+
version: string;
|
|
5
|
+
url: string;
|
|
6
|
+
scheme: "HTTP" | "HTTPS";
|
|
7
|
+
bindAddr: string;
|
|
8
|
+
configPath: string;
|
|
9
|
+
dbPath: string;
|
|
10
|
+
orphanRecoveredCount: number;
|
|
11
|
+
sessionCounts: {
|
|
12
|
+
active: number;
|
|
13
|
+
archived: number;
|
|
14
|
+
total: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface LayoutHandle {
|
|
18
|
+
screen: any;
|
|
19
|
+
refreshHeader(info: HeaderInfo): void;
|
|
20
|
+
refreshSessions(rows: SessionRow[]): void;
|
|
21
|
+
appendLog(record: LogRecord): void;
|
|
22
|
+
setSelectionListener(listener: (index: number) => void): void;
|
|
23
|
+
destroy(): void;
|
|
24
|
+
}
|
|
25
|
+
export declare function buildLayout(): LayoutHandle;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
// neo-blessed 是 CJS,没有匹配 @types/blessed 的 default export,统一当 any 用。
|
|
4
|
+
const blessed = require("neo-blessed");
|
|
5
|
+
const HEADER_HEIGHT = 6;
|
|
6
|
+
const SESSIONS_HEIGHT = 12;
|
|
7
|
+
const LOG_TOP = HEADER_HEIGHT + SESSIONS_HEIGHT;
|
|
8
|
+
const LOG_BUFFER_LIMIT = 1000;
|
|
9
|
+
const RENDER_THROTTLE_MS = 50;
|
|
10
|
+
export function buildLayout() {
|
|
11
|
+
const screen = blessed.screen({
|
|
12
|
+
smartCSR: true,
|
|
13
|
+
fullUnicode: true,
|
|
14
|
+
title: "wand",
|
|
15
|
+
autoPadding: true,
|
|
16
|
+
warnings: false,
|
|
17
|
+
});
|
|
18
|
+
const header = blessed.box({
|
|
19
|
+
parent: screen,
|
|
20
|
+
top: 0,
|
|
21
|
+
left: 0,
|
|
22
|
+
width: "100%",
|
|
23
|
+
height: HEADER_HEIGHT,
|
|
24
|
+
border: { type: "line" },
|
|
25
|
+
label: " wand ",
|
|
26
|
+
tags: true,
|
|
27
|
+
style: {
|
|
28
|
+
border: { fg: "cyan" },
|
|
29
|
+
label: { fg: "cyan", bold: true },
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const sessions = blessed.list({
|
|
33
|
+
parent: screen,
|
|
34
|
+
top: HEADER_HEIGHT,
|
|
35
|
+
left: 0,
|
|
36
|
+
width: "100%",
|
|
37
|
+
height: SESSIONS_HEIGHT,
|
|
38
|
+
border: { type: "line" },
|
|
39
|
+
label: " Sessions ",
|
|
40
|
+
keys: true,
|
|
41
|
+
mouse: false,
|
|
42
|
+
vi: false,
|
|
43
|
+
tags: true,
|
|
44
|
+
scrollable: true,
|
|
45
|
+
scrollbar: { ch: " ", style: { bg: "gray" } },
|
|
46
|
+
style: {
|
|
47
|
+
border: { fg: "gray" },
|
|
48
|
+
label: { fg: "cyan", bold: true },
|
|
49
|
+
selected: { bg: "blue", fg: "white" },
|
|
50
|
+
item: { fg: "white" },
|
|
51
|
+
},
|
|
52
|
+
items: [],
|
|
53
|
+
});
|
|
54
|
+
// 日志面板故意关掉 tags:用户日志可能含 `{...}`,开 tags 会被误解析。
|
|
55
|
+
// 颜色通过 ANSI 转义码注入,blessed.log 能正确渲染 ANSI。
|
|
56
|
+
const logbox = blessed.log({
|
|
57
|
+
parent: screen,
|
|
58
|
+
top: LOG_TOP,
|
|
59
|
+
left: 0,
|
|
60
|
+
width: "100%",
|
|
61
|
+
bottom: 1,
|
|
62
|
+
border: { type: "line" },
|
|
63
|
+
label: " Logs ",
|
|
64
|
+
tags: false,
|
|
65
|
+
scrollable: true,
|
|
66
|
+
scrollback: LOG_BUFFER_LIMIT,
|
|
67
|
+
scrollbar: { ch: " ", style: { bg: "gray" } },
|
|
68
|
+
style: {
|
|
69
|
+
border: { fg: "gray" },
|
|
70
|
+
label: { fg: "cyan", bold: true },
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
const hint = blessed.box({
|
|
74
|
+
parent: screen,
|
|
75
|
+
bottom: 0,
|
|
76
|
+
left: 0,
|
|
77
|
+
width: "100%",
|
|
78
|
+
height: 1,
|
|
79
|
+
tags: true,
|
|
80
|
+
content: "{gray-fg}[q]uit [r]efresh [↑↓] navigate{/}",
|
|
81
|
+
});
|
|
82
|
+
// 让 sessions 默认获得焦点以接收键盘事件
|
|
83
|
+
sessions.focus();
|
|
84
|
+
let renderPending = false;
|
|
85
|
+
let renderTimer = null;
|
|
86
|
+
function scheduleRender() {
|
|
87
|
+
if (renderPending)
|
|
88
|
+
return;
|
|
89
|
+
renderPending = true;
|
|
90
|
+
renderTimer = setTimeout(() => {
|
|
91
|
+
renderPending = false;
|
|
92
|
+
renderTimer = null;
|
|
93
|
+
try {
|
|
94
|
+
screen.render();
|
|
95
|
+
}
|
|
96
|
+
catch { /* destroyed */ }
|
|
97
|
+
}, RENDER_THROTTLE_MS);
|
|
98
|
+
renderTimer.unref?.();
|
|
99
|
+
}
|
|
100
|
+
function refreshHeader(info) {
|
|
101
|
+
const orphan = info.orphanRecoveredCount > 0
|
|
102
|
+
? ` {gray-fg}(${info.orphanRecoveredCount} orphan PTYs cleaned){/}`
|
|
103
|
+
: "";
|
|
104
|
+
const counts = info.sessionCounts;
|
|
105
|
+
const sess = `${counts.active} active · ${counts.archived} archived · ${counts.total} total`;
|
|
106
|
+
const lines = [
|
|
107
|
+
`{cyan-fg}Version{/} ${info.version}`,
|
|
108
|
+
`{cyan-fg}URL{/} ${info.url} {gray-fg}(${info.scheme}, bind ${info.bindAddr}){/}`,
|
|
109
|
+
`{cyan-fg}Config{/} ${info.configPath}`,
|
|
110
|
+
`{cyan-fg}Database{/} ${info.dbPath}`,
|
|
111
|
+
`{cyan-fg}Sessions{/} ${sess}${orphan}`,
|
|
112
|
+
];
|
|
113
|
+
header.setContent(lines.join("\n"));
|
|
114
|
+
scheduleRender();
|
|
115
|
+
}
|
|
116
|
+
function refreshSessions(rows) {
|
|
117
|
+
if (rows.length === 0) {
|
|
118
|
+
sessions.setItems(["{gray-fg}— 暂无会话 —{/}"]);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
sessions.setItems(rows.map(formatRow));
|
|
122
|
+
}
|
|
123
|
+
scheduleRender();
|
|
124
|
+
}
|
|
125
|
+
function appendLog(record) {
|
|
126
|
+
const ts = formatTs(record.ts);
|
|
127
|
+
const tsAnsi = `\x1b[90m${ts}\x1b[39m`;
|
|
128
|
+
const lineAnsi = ansiColorize(record.level, record.line);
|
|
129
|
+
logbox.log(`${tsAnsi} ${lineAnsi}`);
|
|
130
|
+
scheduleRender();
|
|
131
|
+
}
|
|
132
|
+
function setSelectionListener(listener) {
|
|
133
|
+
sessions.on("select item", (_item, index) => listener(index));
|
|
134
|
+
}
|
|
135
|
+
function destroy() {
|
|
136
|
+
if (renderTimer) {
|
|
137
|
+
clearTimeout(renderTimer);
|
|
138
|
+
renderTimer = null;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
screen.destroy();
|
|
142
|
+
}
|
|
143
|
+
catch { /* already destroyed */ }
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
screen,
|
|
147
|
+
refreshHeader,
|
|
148
|
+
refreshSessions,
|
|
149
|
+
appendLog,
|
|
150
|
+
setSelectionListener,
|
|
151
|
+
destroy,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function formatRow(row) {
|
|
155
|
+
const glyphColor = toneColor(row.tone);
|
|
156
|
+
const glyph = `{${glyphColor}-fg}${row.glyph}{/}`;
|
|
157
|
+
const runner = pad(row.runner, 12);
|
|
158
|
+
const cwd = pad(row.cwd, 30);
|
|
159
|
+
const state = `{${toneColor(row.tone)}-fg}${pad(row.state, 9)}{/}`;
|
|
160
|
+
const dur = row.duration ? `{gray-fg}${row.duration}{/}` : "";
|
|
161
|
+
return `${glyph} ${runner} ${cwd} ${state} ${dur}`;
|
|
162
|
+
}
|
|
163
|
+
function pad(str, width) {
|
|
164
|
+
if (str.length >= width)
|
|
165
|
+
return str.slice(0, width);
|
|
166
|
+
return str + " ".repeat(width - str.length);
|
|
167
|
+
}
|
|
168
|
+
function toneColor(tone) {
|
|
169
|
+
switch (tone) {
|
|
170
|
+
case "running": return "green";
|
|
171
|
+
case "idle": return "white";
|
|
172
|
+
case "failed": return "red";
|
|
173
|
+
case "stopped": return "yellow";
|
|
174
|
+
case "exited": return "gray";
|
|
175
|
+
case "archived": return "gray";
|
|
176
|
+
default: return "white";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function ansiColorize(level, line) {
|
|
180
|
+
// 把 line 内本身的颜色重置消掉(避免覆盖我们的颜色),再加上 level 颜色。
|
|
181
|
+
const cleaned = stripAnsi(line);
|
|
182
|
+
if (level === "error")
|
|
183
|
+
return `\x1b[31m${cleaned}\x1b[39m`;
|
|
184
|
+
if (level === "warn")
|
|
185
|
+
return `\x1b[33m${cleaned}\x1b[39m`;
|
|
186
|
+
return cleaned;
|
|
187
|
+
}
|
|
188
|
+
function stripAnsi(line) {
|
|
189
|
+
// eslint-disable-next-line no-control-regex
|
|
190
|
+
return line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
191
|
+
}
|
|
192
|
+
function formatTs(ms) {
|
|
193
|
+
const d = new Date(ms);
|
|
194
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
195
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
196
|
+
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
197
|
+
return `${hh}:${mm}:${ss}`;
|
|
198
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI 日志总线:当 TUI 运行时,劫持 console.* 与 process.stderr.write,
|
|
3
|
+
* 把所有运行期日志路由到 TUI 的 log 面板。
|
|
4
|
+
*
|
|
5
|
+
* 故意不劫持 process.stdout.write —— blessed 自身的渲染走 stdout,
|
|
6
|
+
* 劫持会形成自循环。所有"启动期"经过 stdout 的写入都发生在 TUI 启动之前,
|
|
7
|
+
* 不需要捕获。
|
|
8
|
+
*/
|
|
9
|
+
export type LogLevel = "info" | "warn" | "error";
|
|
10
|
+
export interface LogRecord {
|
|
11
|
+
level: LogLevel;
|
|
12
|
+
line: string;
|
|
13
|
+
ts: number;
|
|
14
|
+
}
|
|
15
|
+
export type LogSink = (record: LogRecord) => void;
|
|
16
|
+
/** 安装日志拦截。重复安装会先恢复再重装。 */
|
|
17
|
+
export declare function installLogBus(sink: LogSink): void;
|
|
18
|
+
/** 还原 console.* 与 stderr.write。多次调用安全。 */
|
|
19
|
+
export declare function restoreLogBus(): void;
|
|
20
|
+
/** 主动写入 TUI 日志面板(用于 wandError/wandWarn 等已识别路径)。
|
|
21
|
+
* 非活跃时不做任何事,调用方应自行判断是否回退到原 stderr。 */
|
|
22
|
+
export declare function wandTuiLog(level: LogLevel, line: string): void;
|
|
23
|
+
export declare function isLogBusActive(): boolean;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI 日志总线:当 TUI 运行时,劫持 console.* 与 process.stderr.write,
|
|
3
|
+
* 把所有运行期日志路由到 TUI 的 log 面板。
|
|
4
|
+
*
|
|
5
|
+
* 故意不劫持 process.stdout.write —— blessed 自身的渲染走 stdout,
|
|
6
|
+
* 劫持会形成自循环。所有"启动期"经过 stdout 的写入都发生在 TUI 启动之前,
|
|
7
|
+
* 不需要捕获。
|
|
8
|
+
*/
|
|
9
|
+
let original = null;
|
|
10
|
+
let activeSink = null;
|
|
11
|
+
let inSink = false;
|
|
12
|
+
function emit(level, args) {
|
|
13
|
+
if (!activeSink)
|
|
14
|
+
return;
|
|
15
|
+
if (inSink) {
|
|
16
|
+
// 防止 sink 内部异常或日志再触发劫持函数导致雪崩。
|
|
17
|
+
if (original) {
|
|
18
|
+
const text = stringifyArgs(args);
|
|
19
|
+
original.stderrWrite.call(process.stderr, text + "\n");
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const text = stringifyArgs(args);
|
|
24
|
+
for (const line of splitLines(text)) {
|
|
25
|
+
if (line.length === 0)
|
|
26
|
+
continue;
|
|
27
|
+
inSink = true;
|
|
28
|
+
try {
|
|
29
|
+
activeSink({ level, line, ts: Date.now() });
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (original) {
|
|
33
|
+
original.stderrWrite.call(process.stderr, `[log-bus] sink threw: ${String(err)}\n`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
inSink = false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function stringifyArgs(args) {
|
|
42
|
+
return args
|
|
43
|
+
.map((a) => {
|
|
44
|
+
if (typeof a === "string")
|
|
45
|
+
return a;
|
|
46
|
+
if (a instanceof Error)
|
|
47
|
+
return a.stack || a.message;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.stringify(a);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return String(a);
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
.join(" ");
|
|
56
|
+
}
|
|
57
|
+
function splitLines(text) {
|
|
58
|
+
return text.replace(/\r\n/g, "\n").split("\n").map((s) => s.replace(/\s+$/, ""));
|
|
59
|
+
}
|
|
60
|
+
/** 安装日志拦截。重复安装会先恢复再重装。 */
|
|
61
|
+
export function installLogBus(sink) {
|
|
62
|
+
if (original)
|
|
63
|
+
restoreLogBus();
|
|
64
|
+
original = {
|
|
65
|
+
consoleLog: console.log,
|
|
66
|
+
consoleInfo: console.info,
|
|
67
|
+
consoleWarn: console.warn,
|
|
68
|
+
consoleError: console.error,
|
|
69
|
+
stderrWrite: process.stderr.write.bind(process.stderr),
|
|
70
|
+
};
|
|
71
|
+
activeSink = sink;
|
|
72
|
+
console.log = (...args) => emit("info", args);
|
|
73
|
+
console.info = (...args) => emit("info", args);
|
|
74
|
+
console.warn = (...args) => emit("warn", args);
|
|
75
|
+
console.error = (...args) => emit("error", args);
|
|
76
|
+
process.stderr.write = ((chunk, encodingOrCb, cb) => {
|
|
77
|
+
const text = typeof chunk === "string"
|
|
78
|
+
? chunk
|
|
79
|
+
: Buffer.isBuffer(chunk)
|
|
80
|
+
? chunk.toString(typeof encodingOrCb === "string" ? encodingOrCb : "utf8")
|
|
81
|
+
: String(chunk);
|
|
82
|
+
emit("error", [text]);
|
|
83
|
+
if (typeof encodingOrCb === "function")
|
|
84
|
+
encodingOrCb();
|
|
85
|
+
else if (typeof cb === "function")
|
|
86
|
+
cb();
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** 还原 console.* 与 stderr.write。多次调用安全。 */
|
|
91
|
+
export function restoreLogBus() {
|
|
92
|
+
if (!original)
|
|
93
|
+
return;
|
|
94
|
+
console.log = original.consoleLog;
|
|
95
|
+
console.info = original.consoleInfo;
|
|
96
|
+
console.warn = original.consoleWarn;
|
|
97
|
+
console.error = original.consoleError;
|
|
98
|
+
process.stderr.write = original.stderrWrite;
|
|
99
|
+
original = null;
|
|
100
|
+
activeSink = null;
|
|
101
|
+
}
|
|
102
|
+
/** 主动写入 TUI 日志面板(用于 wandError/wandWarn 等已识别路径)。
|
|
103
|
+
* 非活跃时不做任何事,调用方应自行判断是否回退到原 stderr。 */
|
|
104
|
+
export function wandTuiLog(level, line) {
|
|
105
|
+
if (!activeSink)
|
|
106
|
+
return;
|
|
107
|
+
emit(level, [line]);
|
|
108
|
+
}
|
|
109
|
+
export function isLogBusActive() {
|
|
110
|
+
return activeSink !== null;
|
|
111
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** 把绝对时间字符串(ISO)转为简短的相对时长,例:`2m`、`10m`、`1h`、`3d`。 */
|
|
2
|
+
export function relativeAge(fromIso, nowMs = Date.now()) {
|
|
3
|
+
const t = Date.parse(fromIso);
|
|
4
|
+
if (!Number.isFinite(t))
|
|
5
|
+
return "—";
|
|
6
|
+
const seconds = Math.max(0, Math.floor((nowMs - t) / 1000));
|
|
7
|
+
if (seconds < 60)
|
|
8
|
+
return `${seconds}s`;
|
|
9
|
+
const minutes = Math.floor(seconds / 60);
|
|
10
|
+
if (minutes < 60)
|
|
11
|
+
return `${minutes}m`;
|
|
12
|
+
const hours = Math.floor(minutes / 60);
|
|
13
|
+
if (hours < 24)
|
|
14
|
+
return `${hours}h`;
|
|
15
|
+
const days = Math.floor(hours / 24);
|
|
16
|
+
return `${days}d`;
|
|
17
|
+
}
|
|
18
|
+
/** 计算两个时间点之间的相对时长(结束 - 开始)。 */
|
|
19
|
+
export function durationBetween(startIso, endIso) {
|
|
20
|
+
if (!endIso)
|
|
21
|
+
return relativeAge(startIso);
|
|
22
|
+
const start = Date.parse(startIso);
|
|
23
|
+
const end = Date.parse(endIso);
|
|
24
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start)
|
|
25
|
+
return "—";
|
|
26
|
+
return relativeAge(startIso, end);
|
|
27
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { SessionSnapshot } from "../types.js";
|
|
2
|
+
export interface SessionRow {
|
|
3
|
+
id: string;
|
|
4
|
+
glyph: string;
|
|
5
|
+
runner: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
state: string;
|
|
8
|
+
duration: string;
|
|
9
|
+
/** 用于上色的语义级别(blessed tag 由调用方决定)。 */
|
|
10
|
+
tone: "running" | "idle" | "archived" | "exited" | "failed" | "stopped";
|
|
11
|
+
}
|
|
12
|
+
/** 把绝对路径压缩为友好显示:`~/foo` 或者长路径只保留尾部。 */
|
|
13
|
+
export declare function shortenCwd(cwd: string, max?: number): string;
|
|
14
|
+
/** 从 SessionSnapshot 推导出一行表格数据。状态规则见 plan。 */
|
|
15
|
+
export declare function formatSession(snap: SessionSnapshot, now?: number): SessionRow;
|
|
16
|
+
/** 把行集按"活跃优先 → idle → 已结束 → archived"排序,再按开始时间倒序。 */
|
|
17
|
+
export declare function sortRows(snaps: SessionSnapshot[]): SessionSnapshot[];
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { durationBetween, relativeAge } from "./relative-time.js";
|
|
4
|
+
/** 把绝对路径压缩为友好显示:`~/foo` 或者长路径只保留尾部。 */
|
|
5
|
+
export function shortenCwd(cwd, max = 28) {
|
|
6
|
+
const home = os.homedir();
|
|
7
|
+
let out = cwd;
|
|
8
|
+
if (home && cwd.startsWith(home))
|
|
9
|
+
out = "~" + cwd.slice(home.length);
|
|
10
|
+
if (out.length <= max)
|
|
11
|
+
return out;
|
|
12
|
+
// 保留头部 1 段 + 尾部尽可能多
|
|
13
|
+
const parts = out.split(path.sep).filter(Boolean);
|
|
14
|
+
if (parts.length <= 2)
|
|
15
|
+
return "…" + out.slice(out.length - max + 1);
|
|
16
|
+
const head = parts[0].startsWith("~") ? parts[0] : "";
|
|
17
|
+
const tail = parts.slice(-2).join(path.sep);
|
|
18
|
+
const candidate = (head ? head + path.sep : "/") + "…" + path.sep + tail;
|
|
19
|
+
return candidate.length <= max ? candidate : "…" + tail.slice(tail.length - max + 1);
|
|
20
|
+
}
|
|
21
|
+
/** 从 SessionSnapshot 推导出一行表格数据。状态规则见 plan。 */
|
|
22
|
+
export function formatSession(snap, now = Date.now()) {
|
|
23
|
+
const runner = (snap.runner || snap.provider || "pty").toString();
|
|
24
|
+
const cwd = shortenCwd(snap.cwd);
|
|
25
|
+
if (snap.archived) {
|
|
26
|
+
return {
|
|
27
|
+
id: snap.id,
|
|
28
|
+
glyph: "○",
|
|
29
|
+
runner,
|
|
30
|
+
cwd,
|
|
31
|
+
state: "archived",
|
|
32
|
+
duration: "",
|
|
33
|
+
tone: "archived",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
switch (snap.status) {
|
|
37
|
+
case "running": {
|
|
38
|
+
const hasTask = typeof snap.currentTaskTitle === "string" && snap.currentTaskTitle.length > 0;
|
|
39
|
+
return {
|
|
40
|
+
id: snap.id,
|
|
41
|
+
glyph: "●",
|
|
42
|
+
runner,
|
|
43
|
+
cwd,
|
|
44
|
+
state: hasTask ? "running" : "idle",
|
|
45
|
+
duration: relativeAge(snap.startedAt, now),
|
|
46
|
+
tone: hasTask ? "running" : "idle",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
case "failed":
|
|
50
|
+
return {
|
|
51
|
+
id: snap.id,
|
|
52
|
+
glyph: "○",
|
|
53
|
+
runner,
|
|
54
|
+
cwd,
|
|
55
|
+
state: "failed",
|
|
56
|
+
duration: durationBetween(snap.startedAt, snap.endedAt),
|
|
57
|
+
tone: "failed",
|
|
58
|
+
};
|
|
59
|
+
case "stopped":
|
|
60
|
+
return {
|
|
61
|
+
id: snap.id,
|
|
62
|
+
glyph: "○",
|
|
63
|
+
runner,
|
|
64
|
+
cwd,
|
|
65
|
+
state: "stopped",
|
|
66
|
+
duration: durationBetween(snap.startedAt, snap.endedAt),
|
|
67
|
+
tone: "stopped",
|
|
68
|
+
};
|
|
69
|
+
case "exited":
|
|
70
|
+
default:
|
|
71
|
+
return {
|
|
72
|
+
id: snap.id,
|
|
73
|
+
glyph: "○",
|
|
74
|
+
runner,
|
|
75
|
+
cwd,
|
|
76
|
+
state: "exited",
|
|
77
|
+
duration: durationBetween(snap.startedAt, snap.endedAt),
|
|
78
|
+
tone: "exited",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** 把行集按"活跃优先 → idle → 已结束 → archived"排序,再按开始时间倒序。 */
|
|
83
|
+
export function sortRows(snaps) {
|
|
84
|
+
const rank = {
|
|
85
|
+
running: 0,
|
|
86
|
+
idle: 1,
|
|
87
|
+
failed: 2,
|
|
88
|
+
stopped: 3,
|
|
89
|
+
exited: 4,
|
|
90
|
+
archived: 5,
|
|
91
|
+
};
|
|
92
|
+
return [...snaps].sort((a, b) => {
|
|
93
|
+
const ra = a.archived
|
|
94
|
+
? rank.archived
|
|
95
|
+
: a.status === "running"
|
|
96
|
+
? a.currentTaskTitle
|
|
97
|
+
? rank.running
|
|
98
|
+
: rank.idle
|
|
99
|
+
: rank[a.status] ?? 99;
|
|
100
|
+
const rb = b.archived
|
|
101
|
+
? rank.archived
|
|
102
|
+
: b.status === "running"
|
|
103
|
+
? b.currentTaskTitle
|
|
104
|
+
? rank.running
|
|
105
|
+
: rank.idle
|
|
106
|
+
: rank[b.status] ?? 99;
|
|
107
|
+
if (ra !== rb)
|
|
108
|
+
return ra - rb;
|
|
109
|
+
return b.startedAt.localeCompare(a.startedAt);
|
|
110
|
+
});
|
|
111
|
+
}
|