@aalis/plugin-cli 0.12.2 → 0.14.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.
- package/dist/chat-log.d.ts +28 -0
- package/dist/chat-log.d.ts.map +1 -0
- package/dist/chat-log.js +104 -0
- package/dist/chat-log.js.map +1 -0
- package/dist/fold.d.ts +34 -0
- package/dist/fold.d.ts.map +1 -0
- package/dist/fold.js +140 -0
- package/dist/fold.js.map +1 -0
- package/dist/index.d.ts +18 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +575 -237
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +104 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +104 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/session-view.d.ts +64 -0
- package/dist/session-view.d.ts.map +1 -0
- package/dist/session-view.js +250 -0
- package/dist/session-view.js.map +1 -0
- package/dist/texts.d.ts +207 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +108 -0
- package/dist/texts.js.map +1 -0
- package/package.json +26 -10
- package/dist/chat-buffer.d.ts +0 -24
- package/dist/chat-buffer.d.ts.map +0 -1
- package/dist/chat-buffer.js +0 -52
- package/dist/chat-buffer.js.map +0 -1
- package/dist/log-file.d.ts +0 -4
- package/dist/log-file.d.ts.map +0 -1
- package/dist/log-file.js +0 -17
- package/dist/log-file.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,55 +1,67 @@
|
|
|
1
1
|
import { stdin as input, stdout as output } from 'node:process';
|
|
2
2
|
import * as readline from 'node:readline';
|
|
3
|
+
import { approvals, clipForCard, sanitizeForCard, } from '@aalis/api-approval';
|
|
4
|
+
import { commands } from '@aalis/api-commands';
|
|
5
|
+
import { unmetDependencies } from '@aalis/api-doctor';
|
|
3
6
|
import { hostConfig } from '@aalis/api-host-config';
|
|
7
|
+
import { hostLogs } from '@aalis/api-host-logs';
|
|
8
|
+
import { i18n, localizeFor } from '@aalis/api-i18n';
|
|
4
9
|
import { persona } from '@aalis/api-persona';
|
|
5
|
-
import { getPlatformAdapters, platform } from '@aalis/api-platform';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
10
|
+
import { getPlatformAdapters, platform, resolvePlatformBySession, } from '@aalis/api-platform';
|
|
11
|
+
import { isUnfiled, sessionManager } from '@aalis/api-session-manager';
|
|
12
|
+
import { sessionProtocol } from '@aalis/api-session-protocol';
|
|
13
|
+
import { storage } from '@aalis/api-storage';
|
|
14
|
+
import { config, definePlugin, defineService, events, lifecycle, logger, optional, pluginsService, provide, services, } from '@aalis/core';
|
|
8
15
|
import { defineConfig, parseConfig } from '@aalis/schema-config';
|
|
16
|
+
import { textOf } from '@aalis/schema-i18n';
|
|
9
17
|
import chalk from 'chalk';
|
|
10
18
|
import cliTruncate from 'cli-truncate';
|
|
11
19
|
import stringWidth from 'string-width';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
20
|
+
import { ChatLog } from './chat-log.js';
|
|
21
|
+
import { groupSummary, languageOf, toolLine } from './fold.js';
|
|
22
|
+
import { SessionView } from './session-view.js';
|
|
23
|
+
import { t } from './texts.js';
|
|
24
|
+
// ----- 服务描述符(调用型:业务层就是提供者的形状)-----
|
|
15
25
|
export const cli = defineService('cli');
|
|
16
26
|
// ===== 插件元数据 =====
|
|
17
27
|
const name = '@aalis/plugin-cli';
|
|
18
28
|
const configSchema = defineConfig({
|
|
19
29
|
sessionId: {
|
|
20
30
|
type: 'string',
|
|
21
|
-
label: '
|
|
31
|
+
label: t('sessionIdLabel'),
|
|
22
32
|
default: 'cli-default',
|
|
23
33
|
onInvalid: 'error',
|
|
24
|
-
description: '
|
|
34
|
+
description: t('sessionIdDescription'),
|
|
25
35
|
},
|
|
26
36
|
lastView: {
|
|
27
37
|
type: 'string',
|
|
28
|
-
label: '
|
|
38
|
+
label: t('lastViewLabel'),
|
|
29
39
|
default: 'chat',
|
|
30
|
-
description: '
|
|
40
|
+
description: t('lastViewDescription'),
|
|
31
41
|
},
|
|
32
|
-
prompt: { type: 'string', label: '
|
|
42
|
+
prompt: { type: 'string', label: t('promptLabel'), default: 'You', description: t('promptDescription') },
|
|
33
43
|
maxLogEntries: {
|
|
34
44
|
type: 'number',
|
|
35
|
-
label: '
|
|
45
|
+
label: t('maxLogEntriesLabel'),
|
|
36
46
|
default: 50000,
|
|
37
|
-
description: '
|
|
47
|
+
description: t('maxLogEntriesDescription'),
|
|
38
48
|
},
|
|
39
49
|
startupView: {
|
|
40
50
|
type: 'select',
|
|
41
|
-
label: '
|
|
51
|
+
label: t('startupViewLabel'),
|
|
42
52
|
default: 'last',
|
|
43
|
-
description: '
|
|
53
|
+
description: t('startupViewDescription'),
|
|
44
54
|
options: [
|
|
45
|
-
{ label: '
|
|
46
|
-
{ label: '
|
|
47
|
-
{ label: '
|
|
48
|
-
{ label: '
|
|
49
|
-
{ label: '
|
|
55
|
+
{ label: t('viewLast'), value: 'last' },
|
|
56
|
+
{ label: t('viewChat'), value: 'chat' },
|
|
57
|
+
{ label: t('viewLogs'), value: 'logs' },
|
|
58
|
+
{ label: t('viewStatus'), value: 'status' },
|
|
59
|
+
{ label: t('viewHelp'), value: 'help' },
|
|
50
60
|
],
|
|
51
61
|
},
|
|
52
62
|
});
|
|
63
|
+
/** 本地终端身份:物理访问即运维者本人。接审批面、接会话协议都以它 */
|
|
64
|
+
const CONSOLE = { platform: 'cli', userId: 'console' };
|
|
53
65
|
// ===== 插件入口 =====
|
|
54
66
|
// platform 既被本插件提供又被状态页枚举,只能是可选依赖:required 会让激活等一个由自己提供的服务。
|
|
55
67
|
const uses = {
|
|
@@ -60,13 +72,26 @@ const uses = {
|
|
|
60
72
|
provide,
|
|
61
73
|
services,
|
|
62
74
|
hostConfig: optional(hostConfig),
|
|
75
|
+
/** 日志页:订阅时补发最近的历史(启动期的也在里面),之后接实时 */
|
|
76
|
+
logs: optional(hostLogs),
|
|
63
77
|
platform: optional(platform),
|
|
64
78
|
storage: optional(storage),
|
|
65
79
|
persona: optional(persona),
|
|
80
|
+
/** 登记 cli 审批面:终端界面在场时审批推到这里,应答记在这个登记身份名下 */
|
|
81
|
+
approvals: optional(approvals),
|
|
82
|
+
/** 欢迎行下面的指路:有没有插件没起来,与 /doctor 在不在 */
|
|
83
|
+
plugins: optional(pluginsService),
|
|
84
|
+
commands: optional(commands),
|
|
85
|
+
/** 发出入站之前给配置的会话建档,创建来源写 cli 审批面的登记名 */
|
|
86
|
+
sessionManager: optional(sessionManager),
|
|
87
|
+
/** 界面文字按全局层的语言显示;缺席时按原文 */
|
|
88
|
+
i18n: optional(i18n),
|
|
89
|
+
/** 聊天区她的输出取会话协议里本会话的条目;缺席时只写整条回复 */
|
|
90
|
+
sessionProtocol: optional(sessionProtocol),
|
|
66
91
|
};
|
|
67
92
|
export default definePlugin({
|
|
68
93
|
name,
|
|
69
|
-
displayName: '
|
|
94
|
+
displayName: t('displayName'),
|
|
70
95
|
subsystem: 'platform',
|
|
71
96
|
configSchema,
|
|
72
97
|
provides: [cli, platform],
|
|
@@ -75,7 +100,7 @@ export default definePlugin({
|
|
|
75
100
|
});
|
|
76
101
|
function startCli(caps) {
|
|
77
102
|
const { config, events, logger } = caps;
|
|
78
|
-
const cfg = parseConfig(configSchema, config, logger);
|
|
103
|
+
const cfg = parseConfig(configSchema, config.use, logger.use);
|
|
79
104
|
const cliConfig = {
|
|
80
105
|
prompt: cfg.prompt,
|
|
81
106
|
sessionId: cfg.sessionId,
|
|
@@ -84,13 +109,25 @@ function startCli(caps) {
|
|
|
84
109
|
maxLogEntries: Math.max(1000, cfg.maxLogEntries || configSchema.maxLogEntries.default),
|
|
85
110
|
};
|
|
86
111
|
const sessionId = cliConfig.sessionId;
|
|
112
|
+
const approvalSurface = caps.approvals.use.registerSurface('cli');
|
|
87
113
|
let tui = null;
|
|
114
|
+
/** i18n 渲染界面文字抛错时告警过了:之后再出错不再告警 */
|
|
115
|
+
let renderWarned = false;
|
|
116
|
+
const onRenderError = (err) => {
|
|
117
|
+
if (renderWarned)
|
|
118
|
+
return;
|
|
119
|
+
renderWarned = true;
|
|
120
|
+
logger.use.warn('按全局层的语言渲染界面文字出错,按原文显示(之后再出错不再告警):', err);
|
|
121
|
+
};
|
|
122
|
+
// 终端是 owner 自己看的:界面文字按全局层的语言,不看终端会话自己的语言设置(聊天输出已由网关按会话渲染)。终端不做
|
|
123
|
+
// Markdown 转义
|
|
124
|
+
const localize = value => localizeFor(caps.i18n, value, { host: true }, { onError: onRenderError });
|
|
88
125
|
const adapter = {
|
|
89
126
|
adapterName: 'CLI',
|
|
90
127
|
platform: 'cli',
|
|
91
|
-
|
|
128
|
+
// 终端界面在跑才有连接;后台运行(非 TTY)与界面起来之前没有连接,不是离线
|
|
92
129
|
getConnections() {
|
|
93
|
-
return [{ id: sessionId, platform: 'cli', status:
|
|
130
|
+
return tui?.isRunning() ? [{ id: sessionId, platform: 'cli', status: 'online' }] : [];
|
|
94
131
|
},
|
|
95
132
|
// sessionId 由配置直接给出(不带 platform 前缀),需显式声明只接管自己的 sessionId
|
|
96
133
|
canHandle(sid) {
|
|
@@ -100,72 +137,83 @@ function startCli(caps) {
|
|
|
100
137
|
if (tui)
|
|
101
138
|
tui.pushAssistant(content);
|
|
102
139
|
else
|
|
103
|
-
logger.info(`[cli] ${content}`);
|
|
140
|
+
logger.use.info(`[cli] ${content}`);
|
|
104
141
|
},
|
|
105
142
|
};
|
|
106
|
-
caps.provide(platform, adapter);
|
|
143
|
+
caps.provide.use(platform, adapter);
|
|
107
144
|
const cliService = {
|
|
108
145
|
getSessionId: () => sessionId,
|
|
109
146
|
isRunning: () => tui?.isRunning() ?? false,
|
|
110
147
|
};
|
|
111
|
-
caps.provide(cli, cliService);
|
|
112
|
-
|
|
148
|
+
caps.provide.use(cli, cliService);
|
|
149
|
+
// 附件:终端就是这个会话的平台,显示不了图片本身,写一行说明(类型、名称、大小、本机路径),写出即算送达并照常归档。
|
|
150
|
+
// 路径在支持超链接的终端里可以点开。类型的显示名取本插件字典(归档协议的类别占位符不当显示名)
|
|
151
|
+
const attachmentStorage = caps.storage.use;
|
|
152
|
+
async function attachmentLine(att) {
|
|
153
|
+
const label = `[${localize(t('attachmentKind', { kind: att.kind }))}]`;
|
|
154
|
+
if (/^https?:\/\//i.test(att.data))
|
|
155
|
+
return `${label} ${att.name ?? ''} ${att.data}`.replace(/\s+/g, ' ').trim();
|
|
156
|
+
if (att.data.startsWith('data:') && att.data.includes(';base64,')) {
|
|
157
|
+
const bytes = Math.floor(((att.data.length - att.data.indexOf(',') - 1) * 3) / 4);
|
|
158
|
+
return `${label} ${att.name ?? localize(t('inlineData'))} · ${localize(t('size', { bytes }))}`;
|
|
159
|
+
}
|
|
160
|
+
let path = att.data;
|
|
161
|
+
let size = '';
|
|
162
|
+
try {
|
|
163
|
+
path = await attachmentStorage.resolveLocalPath(att.data, 'read');
|
|
164
|
+
size = localize(t('size', { bytes: (await attachmentStorage.stat(att.data)).size }));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// 取不到本机路径时照写存储地址
|
|
168
|
+
}
|
|
169
|
+
return [`${label} ${att.name ?? path.split('/').pop() ?? ''}`, size, path].filter(Boolean).join(' · ');
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* 写到终端:她回复的正文由会话协议的条目画,终端订着这个会话时不再写一遍(订阅在途时等它落定再定);没订上(会话服务不在、
|
|
173
|
+
* 会话还没建档)时整条写出。系统与指令消息照常写。附件消息的文字先写(非空时),再依次写各附件
|
|
174
|
+
*/
|
|
175
|
+
async function write(msg, attachments) {
|
|
176
|
+
const drawn = msg.source === 'agent' && (await tui?.drawsReplies()) === true;
|
|
177
|
+
if (!drawn && (attachments.length === 0 || msg.content.trim()))
|
|
178
|
+
await adapter.sendMessage(msg.sessionId, msg.content);
|
|
179
|
+
for (const att of attachments)
|
|
180
|
+
await adapter.sendMessage(msg.sessionId, await attachmentLine(att));
|
|
181
|
+
}
|
|
182
|
+
events.use.on('outbound:message', msg => {
|
|
113
183
|
if (msg.sessionId !== sessionId)
|
|
114
184
|
return;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (
|
|
185
|
+
const attachments = (msg.attachments ?? []).map(att => ({ ...att }));
|
|
186
|
+
// 由条目画的正文不再写,其余照常显示。content 已由网关按会话的语言渲染(网关缺席时是原文)
|
|
187
|
+
const written = write(msg, attachments);
|
|
188
|
+
// 出站的平台另有在场的适配器(如 owner 从 WebUI 驱动这个会话)由那个平台认领并记进历史;回执已被别的入口认领的不覆盖。
|
|
189
|
+
// 这两种照样写到终端,不认领、不发 outbound:delivered,免得再记一份。平台没有适配器的(如工作流注入的)由终端认领
|
|
190
|
+
const handedOff = msg.platform !== adapter.platform && getPlatformAdapters(caps.platform).some(a => a.platform === msg.platform);
|
|
191
|
+
if (handedOff || msg.delivery) {
|
|
192
|
+
written.catch(err => logger.use.warn('[cli] 写到终端失败:', err));
|
|
122
193
|
return;
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const stopLiveBuffer = LogHub.default.onEntry(entry => {
|
|
131
|
-
if (liveBufferActive)
|
|
132
|
-
liveBuffer.push(entry);
|
|
194
|
+
}
|
|
195
|
+
// 写出即算送达,同步认领回执(作品通知等要等它);写不出时回执交出原因。送达之后发 outbound:delivered,存档据此把
|
|
196
|
+
// 不是她回复的文字与发出的附件记进历史
|
|
197
|
+
msg.delivery = written.then(() => {
|
|
198
|
+
void events.use.emit('outbound:delivered', { ...msg, attachments, delivery: undefined });
|
|
199
|
+
return { ok: true };
|
|
200
|
+
}, err => ({ ok: false, error: textOf(err) }));
|
|
133
201
|
});
|
|
134
|
-
|
|
202
|
+
// 日志从激活起订阅:先补发最近 2000 条,之后实时接着送,seq 连续。界面起来之前攒着,起来之后交给它
|
|
203
|
+
const earlyLogs = [];
|
|
204
|
+
let onLog = entry => earlyLogs.push(entry);
|
|
205
|
+
const stopLogs = caps.logs.use.subscribe(entry => onLog(entry), { history: 2000 });
|
|
206
|
+
events.use.on('app:started', () => {
|
|
135
207
|
// 非交互终端(日志重定向 / 容器 / systemd):不接管终端、不画界面,控制台日志照常走 stdout;
|
|
136
208
|
// 服务仍在线(平台枚举、子命令分发不受影响),出站消息退化为日志(见 sendMessage)。
|
|
137
209
|
if (!output.isTTY || !input.isTTY) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
logger.info('stdin/stdout 非 TTY,CLI 界面未启动');
|
|
210
|
+
stopLogs();
|
|
211
|
+
logger.use.info('stdin/stdout 非 TTY,CLI 界面未启动');
|
|
141
212
|
return;
|
|
142
213
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
// 文件未就绪可忽略——只是无早期历史
|
|
149
|
-
}
|
|
150
|
-
// 合并:file tail 与 live buffer 都可能含同一 seq,按 seq 去重排序
|
|
151
|
-
const seen = new Set();
|
|
152
|
-
const merged = [];
|
|
153
|
-
for (const e of initial) {
|
|
154
|
-
if (seen.has(e.seq))
|
|
155
|
-
continue;
|
|
156
|
-
seen.add(e.seq);
|
|
157
|
-
merged.push(e);
|
|
158
|
-
}
|
|
159
|
-
for (const e of liveBuffer) {
|
|
160
|
-
if (seen.has(e.seq))
|
|
161
|
-
continue;
|
|
162
|
-
seen.add(e.seq);
|
|
163
|
-
merged.push(e);
|
|
164
|
-
}
|
|
165
|
-
merged.sort((a, b) => a.seq - b.seq);
|
|
166
|
-
liveBufferActive = false;
|
|
167
|
-
stopLiveBuffer();
|
|
168
|
-
tui = new CliTui(caps, cliConfig, sessionId, merged);
|
|
214
|
+
const started = new CliTui(caps, cliConfig, sessionId, approvalSurface, localize, earlyLogs);
|
|
215
|
+
onLog = entry => started.appendLog(entry);
|
|
216
|
+
tui = started;
|
|
169
217
|
tui.start();
|
|
170
218
|
});
|
|
171
219
|
}
|
|
@@ -176,6 +224,8 @@ class CliTui {
|
|
|
176
224
|
caps;
|
|
177
225
|
config;
|
|
178
226
|
sessionId;
|
|
227
|
+
approvalSurface;
|
|
228
|
+
localize;
|
|
179
229
|
view;
|
|
180
230
|
previousView = 'chat';
|
|
181
231
|
/** 非 chat 视图期间进入聊天区的条数(确认提示、回复);header 的 CHAT 页签带计数,切回即清 */
|
|
@@ -185,27 +235,51 @@ class CliTui {
|
|
|
185
235
|
cursor = 0;
|
|
186
236
|
history = [];
|
|
187
237
|
historyIndex = null;
|
|
188
|
-
|
|
238
|
+
/** 提交的行依次投递:会话缺档时要先等建档,后一行不能越过前一行先发出 */
|
|
239
|
+
delivering = Promise.resolve();
|
|
240
|
+
/** 已告警过配置的会话 id 由别的平台适配器认领 */
|
|
241
|
+
warnedForeign = false;
|
|
242
|
+
log = new ChatLog();
|
|
243
|
+
/** 连续的工具调用全部展开(Ctrl+O);不存配置,重启回到收起 */
|
|
244
|
+
expanded = false;
|
|
245
|
+
/** 经会话协议订阅终端的会话,条目进聊天区 */
|
|
246
|
+
session;
|
|
247
|
+
unfollow;
|
|
189
248
|
logLines;
|
|
190
249
|
logScroll = 0;
|
|
191
250
|
logWrap = false;
|
|
192
251
|
statusScroll = 0;
|
|
193
252
|
helpScroll = 0;
|
|
194
|
-
removeLogListener = null;
|
|
195
|
-
// 流式状态:正在生成的 assistant 回复,其行由 ChatBuffer 维护为末尾一段
|
|
196
|
-
streamingContent = '';
|
|
197
|
-
streamedRecently = false;
|
|
198
253
|
renderQueued = false;
|
|
199
254
|
closing = false;
|
|
200
255
|
restoreOnExit = () => restoreTerminalState();
|
|
201
|
-
|
|
256
|
+
/** 推到终端、还没结的审批,按推到的先后;卡上显示第一张 */
|
|
257
|
+
cards = [];
|
|
258
|
+
/** 卡上选中的应答。推出时不选:正在打字时按下的回车不会落到卡上 */
|
|
259
|
+
choice;
|
|
260
|
+
attendance;
|
|
261
|
+
constructor(caps, config, sessionId, approvalSurface, localize, initialEntries = []) {
|
|
202
262
|
this.caps = caps;
|
|
203
263
|
this.config = config;
|
|
204
264
|
this.sessionId = sessionId;
|
|
265
|
+
this.approvalSurface = approvalSurface;
|
|
266
|
+
this.localize = localize;
|
|
205
267
|
this.view = config.startupView === 'last' ? config.lastView : config.startupView;
|
|
206
|
-
//
|
|
207
|
-
// 之后通过 LogHub.onEntry 实时累积;按 seq 单调,下方 onEntry 用 lastSeq 去重避免与 initial 重复。
|
|
268
|
+
// 日志由插件入口的订阅送来:界面起来之前的这一批,之后经 appendLog 一条条接着
|
|
208
269
|
this.logLines = initialEntries.slice();
|
|
270
|
+
// 以本地终端身份接入审批面(与输入消息的 userId 相同)。有按键才上报在场。卡上的文字按全局层的语言渲染,事实原样
|
|
271
|
+
this.attendance = this.approvalSurface.attend(CONSOLE, {
|
|
272
|
+
requested: approval => {
|
|
273
|
+
this.cards.push(approval);
|
|
274
|
+
this.queueRender();
|
|
275
|
+
},
|
|
276
|
+
settled: (approvalId, { state }) => this.closeCard(approvalId, tool => t(SETTLED_LINES[state], { tool })),
|
|
277
|
+
});
|
|
278
|
+
this.session = new SessionView(sessionId, { identity: CONSOLE, surface: approvalSurface }, {
|
|
279
|
+
item: item => this.timelineChanged(this.log.upsert(item)),
|
|
280
|
+
delta: (itemId, text) => this.timelineChanged(this.log.delta(itemId, text)),
|
|
281
|
+
tasks: this.queueRender,
|
|
282
|
+
}, caps.logger.use);
|
|
209
283
|
}
|
|
210
284
|
isRunning() {
|
|
211
285
|
return this.running;
|
|
@@ -216,7 +290,7 @@ class CliTui {
|
|
|
216
290
|
this.running = true;
|
|
217
291
|
// 宣告"我接管终端"——任何写 stdout 的订阅者(runtime/console-sink 等)
|
|
218
292
|
// 自行响应该事件停止写入,CLI 不直接干预日志系统。
|
|
219
|
-
this.caps.events.emit('terminal:claimed', 'cli').catch(() => { });
|
|
293
|
+
this.caps.events.use.emit('terminal:claimed', 'cli').catch(() => { });
|
|
220
294
|
// 1049h: 进入备用屏 / 25l: 隐藏光标 / 1007h: alternate scroll——支持它的终端会把
|
|
221
295
|
// 滚轮翻译成方向键,落到我们的键盘滚动路径上。不开启 SGR 鼠标上报(1000h/1006h):
|
|
222
296
|
// 终端保持原生的文本选择/复制,程序不接管任何鼠标事件。
|
|
@@ -227,99 +301,93 @@ class CliTui {
|
|
|
227
301
|
input.setRawMode(true);
|
|
228
302
|
input.resume();
|
|
229
303
|
process.once('exit', this.restoreOnExit);
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
this.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
lastSeq = entry.seq;
|
|
236
|
-
// 不限长——启动期起累积,渲染仅访问可见窗口,内存价格可接受
|
|
237
|
-
this.logLines.push(entry);
|
|
238
|
-
if (this.logLines.length > this.config.maxLogEntries)
|
|
239
|
-
this.trimLogHead();
|
|
240
|
-
// 用户已主动向上滚(logScroll > 0)时,新日志到来应保持视图锚定在原历史位置,
|
|
241
|
-
// 而非随 raw.length 增长把旧内容挤出可见区。logScroll === 0 表示跟随底部。
|
|
242
|
-
if (this.view === 'logs' && this.logScroll > 0)
|
|
243
|
-
this.logScroll += 1;
|
|
244
|
-
if (this.view === 'logs')
|
|
245
|
-
this.queueRender();
|
|
246
|
-
});
|
|
247
|
-
const assistantName = this.caps.persona.current?.getPersonaName() ?? 'Aalis';
|
|
248
|
-
this.chat.append([
|
|
249
|
-
chalk.gray(this.formatCont()) + chalk.gray(`欢迎使用 ${assistantName}。按 ${chalk.cyan('Ctrl+G')} 查看快捷键。`),
|
|
250
|
-
]);
|
|
251
|
-
// 意图确认不自建通道:走 session-confirm 的公共协调器(authority 的 '*' 回调)——提示以消息
|
|
252
|
-
// 进聊天区(带参数摘要),在输入框回复 y / ys 即可;回复在 inbound:confirm 相位被拦截,不触达 agent。
|
|
253
|
-
// 与 WebUI / OneBot 同一份排队、超时、会话授予语义,CLI 不再维护第二套。
|
|
304
|
+
const welcome = t('welcome', { name: this.personaName() ?? 'Aalis', key: chalk.cyan('Ctrl+G') });
|
|
305
|
+
this.log.append([chalk.gray(this.formatCont()) + chalk.gray(this.localize(welcome))]);
|
|
306
|
+
void this.pointToUnready();
|
|
307
|
+
// 她的输出取会话协议里本会话的条目:会话服务在就接上,换提供者时断开旧的、接上新的
|
|
308
|
+
this.unfollow = this.caps.sessionProtocol.follow(service => this.session.attach(service));
|
|
254
309
|
input.on('keypress', this.handleKeypress);
|
|
255
310
|
output.on('resize', this.queueRender);
|
|
256
|
-
|
|
311
|
+
// 设置变了(全局层的语言、终端会话的人设,在宿主配置文档或会话设置里):界面立即按新的设置重绘。聊天区的本地行写进时定住,
|
|
312
|
+
// 她的回复与工具行随当前设置重绘
|
|
313
|
+
this.caps.events.use.on('session-settings:changed', this.queueRender);
|
|
314
|
+
this.caps.events.use.on('host-config:changed', this.queueRender);
|
|
315
|
+
this.caps.lifecycle.use.onDispose(() => this.stop());
|
|
257
316
|
this.render();
|
|
258
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* 插件状态静置之后,有插件没起来时在欢迎行下面指路:数目是失败与缺服务之和(缺服务按 api-doctor 的共享判据,只在等
|
|
320
|
+
* 别的插件启动的不算),与 /doctor 的「失败」「未就绪」两项一致;有 doctor 指令时指向它,没有时指向日志页
|
|
321
|
+
*/
|
|
322
|
+
async pointToUnready() {
|
|
323
|
+
if (!this.caps.plugins.present)
|
|
324
|
+
return;
|
|
325
|
+
const plugins = this.caps.plugins.use;
|
|
326
|
+
await plugins.idle();
|
|
327
|
+
const status = plugins.getStatus();
|
|
328
|
+
const lacking = [...unmetDependencies(status, this.caps.services.use.names()).values()].filter(u => u.lacking.length > 0);
|
|
329
|
+
const count = status.filter(p => p.state === 'error').length + lacking.length;
|
|
330
|
+
if (count === 0)
|
|
331
|
+
return;
|
|
332
|
+
const hint = (this.caps.commands.present ? this.caps.commands.use.has('doctor') : undefined)
|
|
333
|
+
? t('unreadyDoctor', { count, command: chalk.cyan('/doctor') })
|
|
334
|
+
: t('unreadyLogs', { count, key: chalk.cyan('Ctrl+L') });
|
|
335
|
+
this.log.append([chalk.gray(this.formatCont()) + chalk.yellow(this.localize(hint))]);
|
|
336
|
+
this.queueRender();
|
|
337
|
+
}
|
|
259
338
|
stop() {
|
|
260
339
|
if (this.closing)
|
|
261
340
|
return;
|
|
262
341
|
this.closing = true;
|
|
263
342
|
this.running = false;
|
|
264
|
-
this.
|
|
265
|
-
this.removeLogListener = null;
|
|
343
|
+
this.unfollow?.();
|
|
266
344
|
input.off('keypress', this.handleKeypress);
|
|
267
345
|
output.off('resize', this.queueRender);
|
|
268
346
|
process.off('exit', this.restoreOnExit);
|
|
269
347
|
restoreTerminalState();
|
|
270
348
|
// 归还终端——sink 等订阅者可自行恢复
|
|
271
|
-
this.caps.events.emit('terminal:released', 'cli').catch(() => { });
|
|
349
|
+
this.caps.events.use.emit('terminal:released', 'cli').catch(() => { });
|
|
272
350
|
}
|
|
351
|
+
appendLog(entry) {
|
|
352
|
+
// 启动期起累积,渲染只访问可见窗口
|
|
353
|
+
this.logLines.push(entry);
|
|
354
|
+
if (this.logLines.length > this.config.maxLogEntries)
|
|
355
|
+
this.trimLogHead();
|
|
356
|
+
// 用户已主动向上滚(logScroll > 0)时,新日志到来应保持视图锚定在原历史位置,
|
|
357
|
+
// 而非随 raw.length 增长把旧内容挤出可见区。logScroll === 0 表示跟随底部。
|
|
358
|
+
if (this.view === 'logs' && this.logScroll > 0)
|
|
359
|
+
this.logScroll += 1;
|
|
360
|
+
if (this.view === 'logs')
|
|
361
|
+
this.queueRender();
|
|
362
|
+
}
|
|
363
|
+
/** 写一段她名下的本地行(整条回复、指令回复与系统消息、附件说明) */
|
|
273
364
|
pushAssistant(content) {
|
|
274
365
|
if (!content.trim())
|
|
275
366
|
return;
|
|
276
|
-
this.
|
|
367
|
+
this.log.append(this.formatAssistantBlock(content));
|
|
277
368
|
if (this.view !== 'chat')
|
|
278
369
|
this.unreadChat++;
|
|
279
370
|
this.queueRender();
|
|
280
371
|
}
|
|
281
|
-
/**
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
this.chat.endStreaming();
|
|
297
|
-
this.streamingContent = '';
|
|
298
|
-
if (this.view === 'chat')
|
|
299
|
-
this.queueRender();
|
|
300
|
-
}
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
if (!chunk.contentDelta)
|
|
304
|
-
return;
|
|
305
|
-
// 首个块:开一个新的 assistant 条目;上一轮遗留的「已流式」标记一并作废
|
|
306
|
-
if (!this.chat.streaming) {
|
|
307
|
-
this.streamingContent = '';
|
|
308
|
-
this.streamedRecently = false;
|
|
309
|
-
if (this.view !== 'chat') {
|
|
310
|
-
this.unreadChat++;
|
|
311
|
-
this.queueRender();
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
this.streamingContent += chunk.contentDelta;
|
|
315
|
-
// 根据累积内容重建该消息占据的行
|
|
316
|
-
this.chat.replaceStreamingBlock(this.formatAssistantBlock(this.streamingContent));
|
|
317
|
-
if (this.view === 'chat')
|
|
318
|
-
this.queueRender();
|
|
372
|
+
/** 她回复的正文由条目画:订着终端的会话(订阅在途时等它落定) */
|
|
373
|
+
drawsReplies() {
|
|
374
|
+
return this.session.drawsReplies();
|
|
375
|
+
}
|
|
376
|
+
/** 条目来了:有字的回复刚出现、又不在聊天页时计一条未读 */
|
|
377
|
+
timelineChanged(fresh) {
|
|
378
|
+
if (fresh && this.view !== 'chat')
|
|
379
|
+
this.unreadChat++;
|
|
380
|
+
this.queueRender();
|
|
381
|
+
}
|
|
382
|
+
/** 终端会话生效的人设的名字:人设服务按这个会话取卡,与回合同一口径;没有人设服务或卡没取名时为 undefined */
|
|
383
|
+
personaName() {
|
|
384
|
+
return this.caps.persona.present
|
|
385
|
+
? this.caps.persona.use.getPersonaName({ sessionId: this.sessionId, platform: 'cli' }) || undefined
|
|
386
|
+
: undefined;
|
|
319
387
|
}
|
|
320
388
|
/** 所有标签对齐到同一列宽,确保 │ 竖线垂直对齐 */
|
|
321
389
|
labelCol() {
|
|
322
|
-
const persona = this.
|
|
390
|
+
const persona = this.personaName() ?? 'Aalis';
|
|
323
391
|
return Math.max(visibleLen(`✦ ${persona}`), // assistant
|
|
324
392
|
visibleLen(`❯ ${this.config.prompt}`), // user
|
|
325
393
|
visibleLen('◆ System'), // system
|
|
@@ -336,14 +404,18 @@ class CliTui {
|
|
|
336
404
|
return `${' '.repeat(col)} ${chalk.gray('│')} `;
|
|
337
405
|
}
|
|
338
406
|
formatAssistantBlock(content) {
|
|
339
|
-
const persona = this.
|
|
407
|
+
const persona = this.personaName() ?? 'Aalis';
|
|
340
408
|
const firstHead = this.formatHead(chalk.green(`✦ ${persona}`));
|
|
341
409
|
const contHead = this.formatCont();
|
|
342
410
|
return content.split('\n').map((line, i) => (i === 0 ? firstHead : contHead) + line);
|
|
343
411
|
}
|
|
344
|
-
handleKeypress =
|
|
412
|
+
handleKeypress = (chunk, key) => {
|
|
345
413
|
if (!this.running)
|
|
346
414
|
return;
|
|
415
|
+
// 卡片只接它推出之后的按键:这次按键让终端变为在场而补推来的卡,owner 还没看到,这次按键照常处理
|
|
416
|
+
const carded = this.cards.length > 0;
|
|
417
|
+
// 终端有输入即在场:最近一次按键在审批服务的时限内,审批就推到这里
|
|
418
|
+
this.attendance.presence(true);
|
|
347
419
|
if (key.ctrl && key.name === 'c') {
|
|
348
420
|
this.stop();
|
|
349
421
|
process.kill(process.pid, 'SIGINT');
|
|
@@ -365,10 +437,20 @@ class CliTui {
|
|
|
365
437
|
this.switchView('help');
|
|
366
438
|
return;
|
|
367
439
|
}
|
|
440
|
+
if (key.ctrl && key.name === 'o') {
|
|
441
|
+
this.expanded = !this.expanded;
|
|
442
|
+
this.queueRender();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
368
445
|
if (key.name === 'escape') {
|
|
369
446
|
this.switchView(this.previousView);
|
|
370
447
|
return;
|
|
371
448
|
}
|
|
449
|
+
// 审批卡是模态的:卡在时除了上面的切换视图与退出,按键只用来应答;打字与回车不算应答,也不进输入框
|
|
450
|
+
if (carded) {
|
|
451
|
+
this.handleCardKey(key);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
372
454
|
if (this.view === 'logs') {
|
|
373
455
|
this.handleLogsKey(key);
|
|
374
456
|
return;
|
|
@@ -395,7 +477,7 @@ class CliTui {
|
|
|
395
477
|
return;
|
|
396
478
|
}
|
|
397
479
|
if (key.name === 'return') {
|
|
398
|
-
|
|
480
|
+
this.submitLine();
|
|
399
481
|
return;
|
|
400
482
|
}
|
|
401
483
|
if (key.name === 'backspace') {
|
|
@@ -447,6 +529,40 @@ class CliTui {
|
|
|
447
529
|
this.queueRender();
|
|
448
530
|
}
|
|
449
531
|
};
|
|
532
|
+
/** ← 选批准、→ 选拒绝,回车确定选中的一项;Ctrl+Y 直接批准、Ctrl+N 直接拒绝 */
|
|
533
|
+
handleCardKey(key) {
|
|
534
|
+
if (key.ctrl && (key.name === 'y' || key.name === 'n')) {
|
|
535
|
+
this.answer(key.name === 'y' ? 'approve' : 'deny');
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (key.name === 'left' || key.name === 'right') {
|
|
539
|
+
this.choice = key.name === 'left' ? 'approve' : 'deny';
|
|
540
|
+
this.queueRender();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (key.name === 'return' && this.choice)
|
|
544
|
+
this.answer(this.choice);
|
|
545
|
+
}
|
|
546
|
+
/** 应答卡上的第一张。成功时结算同步推回、卡片随之关掉;被拒的应答(已结算、审批服务不在等)同样关卡并写明原因 */
|
|
547
|
+
answer(decision) {
|
|
548
|
+
const card = this.cards[0];
|
|
549
|
+
const reply = this.attendance.respond({ approvalId: card.id, decision, contentHash: card.contentHash });
|
|
550
|
+
if (!reply.ok)
|
|
551
|
+
this.closeCard(card.id, tool => t('answerRefused', { tool, reason: reply.message }));
|
|
552
|
+
}
|
|
553
|
+
/** 审批结算了:卡片关掉,聊天区留一行(line 按工具名给出这一行) */
|
|
554
|
+
closeCard(approvalId, line) {
|
|
555
|
+
const index = this.cards.findIndex(card => card.id === approvalId);
|
|
556
|
+
if (index < 0)
|
|
557
|
+
return;
|
|
558
|
+
const [card] = this.cards.splice(index, 1);
|
|
559
|
+
if (index === 0)
|
|
560
|
+
this.choice = undefined;
|
|
561
|
+
this.log.append([`${this.formatHead(chalk.yellow('◆ System'))}${this.localize(line(sanitizeForCard(card.tool)))}`]);
|
|
562
|
+
if (this.view !== 'chat')
|
|
563
|
+
this.unreadChat++;
|
|
564
|
+
this.queueRender();
|
|
565
|
+
}
|
|
450
566
|
handleLogsKey(key) {
|
|
451
567
|
if (key.ctrl && key.name === 'w') {
|
|
452
568
|
this.logWrap = !this.logWrap;
|
|
@@ -485,7 +601,7 @@ class CliTui {
|
|
|
485
601
|
this[field] = Number.MAX_SAFE_INTEGER;
|
|
486
602
|
this.queueRender();
|
|
487
603
|
}
|
|
488
|
-
|
|
604
|
+
submitLine() {
|
|
489
605
|
const text = this.inputLine.trim();
|
|
490
606
|
this.inputLine = '';
|
|
491
607
|
this.cursor = 0;
|
|
@@ -498,20 +614,51 @@ class CliTui {
|
|
|
498
614
|
this.history.shift();
|
|
499
615
|
const firstHead = this.formatHead(chalk.cyan(`❯ ${this.config.prompt}`));
|
|
500
616
|
const contHead = this.formatCont();
|
|
501
|
-
this.
|
|
617
|
+
this.log.append(text.split('\n').map((line, i) => (i === 0 ? firstHead : contHead) + line));
|
|
502
618
|
// 指令解析已统一到全局 inbound:command 相位(plugin-commands)。
|
|
503
619
|
// 适配器不再内联解析:未注册命令被放行为普通消息进入 agent,命中命令
|
|
504
620
|
// 由该相位执行并经 outbound:message 回送渲染到 TUI,与 onebot/webui 一致。
|
|
505
621
|
// shutdown/restart 的 TUI 清理由 lifecycle.onDispose(() => this.stop()) 兜底。
|
|
506
|
-
//
|
|
507
|
-
|
|
508
|
-
|
|
622
|
+
// 一行没发出去只记 warn,不挡后面的行
|
|
623
|
+
this.delivering = this.delivering
|
|
624
|
+
.then(() => this.deliver(text))
|
|
625
|
+
.catch(err => this.caps.logger.use.warn('终端消息没有发出:', err));
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* 发出一行。会话在入站之前建档、写创建来源:入站时会话表里已有它,房间收录不会把它当成 IM 房间;会话被删后下一条消息
|
|
629
|
+
* 照样补建。已有的记录只有按 id 兜底建档留下的空记录(没有创建来源、出生平台与绑定,如会话被删后定时任务在它里面派了
|
|
630
|
+
* 子任务)补写创建来源,其余不动。配置的 id 由别的平台适配器认领(配错了)时不建档、不写创建来源:配错的 id 不能把
|
|
631
|
+
* 别人的窗口变成 owner 自己的会话。userId 'console':本地终端身份(物理访问 = 运维者本人),命中 authority 的
|
|
632
|
+
* cli:console owner 快速通道;缺省 userId 会被当成匿名回退 defaultAuthority
|
|
633
|
+
*/
|
|
634
|
+
async deliver(text) {
|
|
635
|
+
const sm = this.caps.sessionManager.present ? this.caps.sessionManager.use : undefined;
|
|
636
|
+
const session = sm?.getSession(this.sessionId);
|
|
637
|
+
const unfiled = !session || isUnfiled(session);
|
|
638
|
+
if (sm && unfiled && !(await this.claimedElsewhere())) {
|
|
639
|
+
await sm.ensureSession(this.sessionId, {}, { createdFrom: this.approvalSurface.name });
|
|
640
|
+
}
|
|
641
|
+
// 订上这个会话再发:这一轮的条目从头画进聊天区
|
|
642
|
+
await this.session.ensureSubscribed();
|
|
643
|
+
await this.caps.events.use.emit('inbound:message', {
|
|
509
644
|
content: text,
|
|
510
645
|
sessionId: this.sessionId,
|
|
511
646
|
platform: 'cli',
|
|
512
647
|
userId: 'console',
|
|
513
648
|
});
|
|
514
649
|
}
|
|
650
|
+
/** 配置的会话 id 由终端以外的平台适配器认领;第一次发现时告警 */
|
|
651
|
+
async claimedElsewhere() {
|
|
652
|
+
const { platform, logger } = this.caps;
|
|
653
|
+
const others = { list: () => platform.list().filter(({ use }) => use.platform !== 'cli') };
|
|
654
|
+
const owner = await resolvePlatformBySession(others, this.sessionId, logger.use);
|
|
655
|
+
if (owner && !this.warnedForeign) {
|
|
656
|
+
this.warnedForeign = true;
|
|
657
|
+
logger.use.warn(`终端配置的会话 ${this.sessionId} 由平台「${owner.use.platform}」(${owner.id})认领:终端不给它建档、` +
|
|
658
|
+
'不写创建来源。改配置里的 sessionId');
|
|
659
|
+
}
|
|
660
|
+
return owner !== undefined;
|
|
661
|
+
}
|
|
515
662
|
recallHistory(delta) {
|
|
516
663
|
if (this.history.length === 0)
|
|
517
664
|
return;
|
|
@@ -550,13 +697,13 @@ class CliTui {
|
|
|
550
697
|
if (view === 'help')
|
|
551
698
|
return;
|
|
552
699
|
// 宿主没提供配置文档时不记:下次启动回到默认视图
|
|
553
|
-
|
|
554
|
-
if (!host)
|
|
700
|
+
if (!this.caps.hostConfig.present)
|
|
555
701
|
return;
|
|
702
|
+
const host = this.caps.hostConfig.use;
|
|
556
703
|
const pluginConfig = host.getPluginConfig(name);
|
|
557
704
|
host.setPluginConfig(name, { ...pluginConfig, lastView: view });
|
|
558
705
|
// 尽力而为:最后视图丢了只是下次回到默认视图,不值得让渲染路径变 async
|
|
559
|
-
host.save().catch(err => this.caps.logger.warn('记录最后视图失败:', err));
|
|
706
|
+
host.save().catch(err => this.caps.logger.use.warn('记录最后视图失败:', err));
|
|
560
707
|
}
|
|
561
708
|
queueRender = () => {
|
|
562
709
|
if (this.renderQueued)
|
|
@@ -572,20 +719,25 @@ class CliTui {
|
|
|
572
719
|
return;
|
|
573
720
|
const width = Math.max(40, output.columns || 100);
|
|
574
721
|
const height = Math.max(14, output.rows || 30);
|
|
575
|
-
//
|
|
576
|
-
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
722
|
+
// 正文下面的框:有审批卡时是卡片;chat 视图是输入框,可变高度(多行输入):内容行数 + 上下边框,最多 8 行内容;
|
|
723
|
+
// 别的视图是一行视图说明(框高 3 行),在正文之后算,滚动值取正文钳制过的
|
|
724
|
+
const box = this.cards.length > 0
|
|
725
|
+
? this.renderCard(width, height)
|
|
726
|
+
: this.view === 'chat'
|
|
727
|
+
? this.renderInputBox(width, this.computeInputDisplayLines(width))
|
|
728
|
+
: undefined;
|
|
729
|
+
// 布局:header(1) + sep(1) + body + box + footer(1)
|
|
730
|
+
const bodyHeight = Math.max(3, height - 3 - (box?.length ?? 3));
|
|
731
|
+
const body = this.renderBody(width, bodyHeight);
|
|
580
732
|
const out = [];
|
|
581
733
|
out.push(this.renderHeader(width));
|
|
582
734
|
out.push(chalk.gray(LINE_H.repeat(width)));
|
|
583
|
-
out.push(...
|
|
584
|
-
out.push(...this.renderInputBox(width,
|
|
735
|
+
out.push(...body);
|
|
736
|
+
out.push(...(box ?? this.renderInputBox(width, [this.viewLine(width)])));
|
|
585
737
|
out.push(this.renderFooter(width));
|
|
586
738
|
output.write('\x1b[?25l\x1b[H');
|
|
587
739
|
output.write(out.map(line => clearLine(line, width)).join('\n'));
|
|
588
|
-
if (this.view === 'chat') {
|
|
740
|
+
if (this.view === 'chat' && this.cards.length === 0) {
|
|
589
741
|
// 计算光标在多行输入中的 (row, col)
|
|
590
742
|
const { row: cRow, col: cCol } = this.cursorPosInInput();
|
|
591
743
|
// 行号布局(1-based):1=header, 2=sep, 3..2+bodyHeight=body, 之后是 inputBox(顶/中.../底), 最后是 footer
|
|
@@ -597,7 +749,8 @@ class CliTui {
|
|
|
597
749
|
}
|
|
598
750
|
}
|
|
599
751
|
renderHeader(width) {
|
|
600
|
-
|
|
752
|
+
// 卡没取名时写 -,同状态页
|
|
753
|
+
const persona = this.personaName() ?? '-';
|
|
601
754
|
const left = ` ${chalk.bold.magenta('●')} ${chalk.bold('Aalis')} ${chalk.gray('·')} ${chalk.cyan(persona)} `;
|
|
602
755
|
const tabs = ['chat', 'logs', 'status', 'help']
|
|
603
756
|
.map(v => {
|
|
@@ -650,17 +803,41 @@ class CliTui {
|
|
|
650
803
|
if (this.view === 'status')
|
|
651
804
|
return this.getStatusViewLines();
|
|
652
805
|
if (this.view === 'help')
|
|
653
|
-
return this.getHelpViewLines();
|
|
806
|
+
return this.getHelpViewLines(width);
|
|
654
807
|
return this.getChatViewLines(width);
|
|
655
808
|
}
|
|
656
809
|
getChatViewLines(width) {
|
|
657
|
-
const lines = [];
|
|
658
810
|
const inner = width - 2; // 缩进 2 列
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
811
|
+
// 宽度感知截断为单行;超长内容直接尾部省略。
|
|
812
|
+
return this.log
|
|
813
|
+
.layout(this.session.taskRunning)
|
|
814
|
+
.flatMap(part => ('lines' in part ? part.lines : this.rowLines(part.row)))
|
|
815
|
+
.map(raw => ` ${clipAnsi(raw, inner)}`);
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* 条目的一行画成的行:她的正文另起一块、第一行带人设名的标签;工具调用一行,收起的一组一行(展开时这一组的工具调用各一行),
|
|
819
|
+
* 都用续行的空标签头。思考、提问、通知与回合条目不画:输入回显、指令回复与附件说明是本地行,思考不显示
|
|
820
|
+
*/
|
|
821
|
+
rowLines(row) {
|
|
822
|
+
if ('group' in row) {
|
|
823
|
+
const calls = row.group.filter((item) => item.kind === 'tool-call');
|
|
824
|
+
if (this.expanded)
|
|
825
|
+
return calls.map(call => this.callLine(call));
|
|
826
|
+
return [this.formatCont() + chalk.gray(this.localize(groupSummary(calls)))];
|
|
662
827
|
}
|
|
663
|
-
|
|
828
|
+
const { item } = row;
|
|
829
|
+
if (item.kind === 'agent-message')
|
|
830
|
+
return item.text.trim() ? this.formatAssistantBlock(item.text) : [];
|
|
831
|
+
return item.kind === 'tool-call' ? [this.callLine(item)] : [];
|
|
832
|
+
}
|
|
833
|
+
/** 工具调用一行:做了什么与状态,等你批准写成黄色、失败写成红色 */
|
|
834
|
+
callLine(call) {
|
|
835
|
+
const { summary, status } = toolLine(call, this.session.taskRunning, languageOf(this.localize));
|
|
836
|
+
const head = this.formatCont() + chalk.gray(this.localize(summary));
|
|
837
|
+
if (!status)
|
|
838
|
+
return head;
|
|
839
|
+
const paint = status.tone === 'attention' ? chalk.yellow : status.tone === 'danger' ? chalk.red : chalk.gray;
|
|
840
|
+
return `${head}${chalk.gray(' · ')}${paint(this.localize(status.text))}`;
|
|
664
841
|
}
|
|
665
842
|
/** 日志视图的增量格式化缓存。logLines 只追加、从不改写,因此已格式化的行
|
|
666
843
|
* 可以按 (width, wrap) 为键永久复用——每帧只格式化"上次之后新增"的条目,
|
|
@@ -684,7 +861,7 @@ class CliTui {
|
|
|
684
861
|
return c.lines;
|
|
685
862
|
}
|
|
686
863
|
/** 内存环形上限:超限时从最旧丢弃约 10% 一块(摊销 O(1)),格式化缓存按 lineCounts
|
|
687
|
-
* 对齐裁剪而非整体失效——避免每次裁剪都 O(全量) 重建。完整日志始终在
|
|
864
|
+
* 对齐裁剪而非整体失效——避免每次裁剪都 O(全量) 重建。完整日志始终在 logs/latest.log。 */
|
|
688
865
|
trimLogHead() {
|
|
689
866
|
const drop = Math.max(1, Math.ceil(this.config.maxLogEntries / 10));
|
|
690
867
|
this.logLines.splice(0, drop);
|
|
@@ -735,25 +912,32 @@ class CliTui {
|
|
|
735
912
|
}
|
|
736
913
|
}
|
|
737
914
|
getStatusViewLines() {
|
|
738
|
-
const serviceNames = this.caps.services.names();
|
|
915
|
+
const serviceNames = this.caps.services.use.names();
|
|
739
916
|
const adapter = getPlatformAdapters(this.caps.platform).find(a => a.platform === 'cli');
|
|
740
917
|
const connections = adapter?.getConnections?.() ?? [];
|
|
741
|
-
const persona = this.
|
|
742
|
-
const
|
|
918
|
+
const persona = this.personaName() ?? '-';
|
|
919
|
+
const text = this.localize({
|
|
920
|
+
runtime: t('statusRuntime'),
|
|
921
|
+
logs: t('logCount', { count: this.logLines.length }),
|
|
922
|
+
services: t('statusServices', { count: serviceNames.length }),
|
|
923
|
+
connections: t('statusConnections', { count: connections.length }),
|
|
924
|
+
none: t('noConnections'),
|
|
925
|
+
});
|
|
926
|
+
const sec = (title) => chalk.bold.cyan(`▎ ${title}`);
|
|
743
927
|
const kv = (k, v) => ` ${chalk.gray(k.padEnd(14))} ${v}`;
|
|
744
928
|
const out = [];
|
|
745
|
-
out.push(sec(
|
|
929
|
+
out.push(sec(text.runtime));
|
|
746
930
|
out.push(kv('persona', persona));
|
|
747
931
|
out.push(kv('cli session', this.sessionId));
|
|
748
|
-
out.push(kv('log buffer',
|
|
932
|
+
out.push(kv('log buffer', text.logs));
|
|
749
933
|
out.push('');
|
|
750
|
-
out.push(sec(
|
|
934
|
+
out.push(sec(text.services));
|
|
751
935
|
for (const s of serviceNames)
|
|
752
936
|
out.push(` ${chalk.green('●')} ${s}`);
|
|
753
937
|
out.push('');
|
|
754
|
-
out.push(sec(
|
|
938
|
+
out.push(sec(text.connections));
|
|
755
939
|
if (connections.length === 0)
|
|
756
|
-
out.push(` ${chalk.gray(
|
|
940
|
+
out.push(` ${chalk.gray(text.none)}`);
|
|
757
941
|
else
|
|
758
942
|
for (const c of connections) {
|
|
759
943
|
const dot = c.status === 'online' ? chalk.green('●') : c.status === 'connecting' ? chalk.yellow('●') : chalk.red('●');
|
|
@@ -761,50 +945,34 @@ class CliTui {
|
|
|
761
945
|
}
|
|
762
946
|
return out.map(l => (l.length === 0 ? '' : ` ${l}`));
|
|
763
947
|
}
|
|
764
|
-
getHelpViewLines() {
|
|
765
|
-
const
|
|
766
|
-
|
|
948
|
+
getHelpViewLines(width) {
|
|
949
|
+
const help = this.localize(HELP);
|
|
950
|
+
/** text 按宽度折行:第一行接在 head 后面,续行缩进到同一列 */
|
|
951
|
+
const wrap = (head, text, style) => {
|
|
952
|
+
const indent = ' '.repeat(visibleLen(head));
|
|
953
|
+
return wrapWords(text, width - indent.length).map((seg, i) => (i === 0 ? head : indent) + style(seg));
|
|
954
|
+
};
|
|
955
|
+
const sec = (title) => wrap(` ${chalk.bold.cyan('▎')} `, title, chalk.bold.cyan);
|
|
956
|
+
const row = (k, v) => wrap(` ${chalk.cyan(k.padEnd(10))} `, v, chalk.gray);
|
|
767
957
|
const out = [];
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
out.push(
|
|
771
|
-
out
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
out.push('');
|
|
783
|
-
out.push(sec('编辑'));
|
|
784
|
-
out.push(row('Enter', '提交输入'));
|
|
785
|
-
out.push(row('Ctrl+J', '插入换行(macOS Ctrl+Enter)'));
|
|
786
|
-
out.push(row('Shift+Ent', '插入换行(如终端支持)'));
|
|
787
|
-
out.push(row('↑ / ↓', '历史记录回溯(chat 视图)'));
|
|
788
|
-
out.push('');
|
|
789
|
-
out.push(sec('提示'));
|
|
790
|
-
out.push(` ${chalk.gray('• 完整日志写入 data/latest.log,每次启动时上一轮改名为 latest.1.log,保留 5 份;跨重启持续跟随用 tail -F。')}`);
|
|
791
|
-
out.push(` ${chalk.gray('• 退出时自动恢复原终端内容(alternate screen)。')}`);
|
|
792
|
-
out.push(` ${chalk.gray('• 输入以 / 开头会按命令解析,否则发给 agent。')}`);
|
|
793
|
-
out.push(` ${chalk.gray('• ↑/↓、PgUp/PgDn、Home/End 滚动 Logs / Status / Help;选择 / 复制文本直接用终端原生操作。')}`);
|
|
794
|
-
return out.map(l => (l.length === 0 ? '' : ` ${l}`));
|
|
958
|
+
for (const { title, rows } of help.sections)
|
|
959
|
+
out.push(...sec(title), ...rows.flatMap(([k, v]) => row(k, v)), '');
|
|
960
|
+
out.push(...sec(help.tipsTitle), ...help.tips.flatMap(tip => wrap(' ', tip, chalk.gray)));
|
|
961
|
+
return out;
|
|
962
|
+
}
|
|
963
|
+
/** chat 以外的视图在输入框位置的说明:视图名、总行数与滚动值(正文渲染之后取,是钳制过的) */
|
|
964
|
+
viewLine(width) {
|
|
965
|
+
const total = this.view === 'logs'
|
|
966
|
+
? this.logLines.length
|
|
967
|
+
: this.view === 'status'
|
|
968
|
+
? this.getStatusViewLines().length
|
|
969
|
+
: this.getHelpViewLines(width).length;
|
|
970
|
+
const scroll = this.view === 'logs' ? this.logScroll : this.view === 'status' ? this.statusScroll : this.helpScroll;
|
|
971
|
+
return chalk.gray(this.localize(t('viewLine', { view: this.view, total, scroll })));
|
|
795
972
|
}
|
|
796
973
|
/** 计算输入框的多行内容(不含边框、不含 padding) */
|
|
797
974
|
computeInputDisplayLines(width) {
|
|
798
975
|
const inner = width - 2;
|
|
799
|
-
if (this.view !== 'chat') {
|
|
800
|
-
const total = this.view === 'logs'
|
|
801
|
-
? this.logLines.length
|
|
802
|
-
: this.view === 'status'
|
|
803
|
-
? this.getStatusViewLines().length
|
|
804
|
-
: this.getHelpViewLines().length;
|
|
805
|
-
const scroll = this.view === 'logs' ? this.logScroll : this.view === 'status' ? this.statusScroll : this.helpScroll;
|
|
806
|
-
return [chalk.gray(`${this.view} · ${total} 行 · scroll ${scroll} · 按 Ctrl+T 回到聊天`)];
|
|
807
|
-
}
|
|
808
976
|
const inputLines = this.inputLine.split('\n');
|
|
809
977
|
const display = [];
|
|
810
978
|
const promptLen = visibleLen(this.makePrompt());
|
|
@@ -836,29 +1004,173 @@ class CliTui {
|
|
|
836
1004
|
const bot = chalk.gray(`${BOX_BL}${LINE_H.repeat(inner)}${BOX_BR}`);
|
|
837
1005
|
return [top, ...mid, bot];
|
|
838
1006
|
}
|
|
1007
|
+
/**
|
|
1008
|
+
* 审批卡:标题行、事实、应答项,上下边框。子会话里的审批在事实之前多一行「子任务」,写根会话以下的各级标题。卡片最多高到
|
|
1009
|
+
* 正文只剩 3 行:事实放不下时保留首尾几行,写明省略了多少行。事实显示前经 clipForCard 清洗,太长的保留首尾、写明省略了多少字
|
|
1010
|
+
*/
|
|
1011
|
+
renderCard(width, height) {
|
|
1012
|
+
const card = this.cards[0];
|
|
1013
|
+
const inner = width - 2;
|
|
1014
|
+
const tool = sanitizeForCard(card.tool);
|
|
1015
|
+
const session = sanitizeForCard(card.sessionId);
|
|
1016
|
+
const text = this.localize({
|
|
1017
|
+
heading: t('cardHeading'),
|
|
1018
|
+
subject: this.cards.length > 1
|
|
1019
|
+
? t('cardSubjectWaiting', { tool, session, count: this.cards.length - 1 })
|
|
1020
|
+
: t('cardSubject', { tool, session }),
|
|
1021
|
+
labels: FACT_LABELS,
|
|
1022
|
+
chain: card.chain?.map(level => level.title),
|
|
1023
|
+
approve: t('approve'),
|
|
1024
|
+
deny: t('deny'),
|
|
1025
|
+
keys: t('cardKeys'),
|
|
1026
|
+
});
|
|
1027
|
+
const title = `${chalk.bold.yellow(text.heading)} ${text.subject}`;
|
|
1028
|
+
const rows = cardFacts(card.facts, text.chain, text.labels);
|
|
1029
|
+
const labelWidth = Math.max(0, ...rows.map(([label]) => visibleLen(label)));
|
|
1030
|
+
const clipped = (value) => {
|
|
1031
|
+
const { head, omitted, tail } = clipForCard(value, CARD_FACT_LIMIT);
|
|
1032
|
+
return omitted === 0 ? head : `${head}${this.localize(t('charsOmitted', { count: omitted }))}${tail}`;
|
|
1033
|
+
};
|
|
1034
|
+
const facts = rows.flatMap(([label, value]) => factLines(label, labelWidth, clipped(value), inner - 2));
|
|
1035
|
+
const option = (decision, label) => this.choice === decision ? chalk.inverse(`❯ ${label}`) : ` ${label}`;
|
|
1036
|
+
const choices = `${option('approve', text.approve)} ${option('deny', text.deny)}`;
|
|
1037
|
+
// 应答行放不下按键提示时,提示另起一行(再放不下就折行)
|
|
1038
|
+
const answer = visibleLen(`${choices} ${text.keys}`) < inner
|
|
1039
|
+
? [`${choices} ${chalk.gray(text.keys)}`]
|
|
1040
|
+
: [choices, ...wrapWords(text.keys, inner - 1).map(line => chalk.gray(line))];
|
|
1041
|
+
// 高度:header、sep、footer 各一行,正文至少 3 行,卡片的上下边框、标题行与应答行
|
|
1042
|
+
const room = height - 9 - answer.length;
|
|
1043
|
+
const shown = facts.length <= room
|
|
1044
|
+
? facts
|
|
1045
|
+
: [
|
|
1046
|
+
...facts.slice(0, Math.ceil((room - 1) / 2)),
|
|
1047
|
+
chalk.gray(this.localize(t('linesOmitted', { count: facts.length - room + 1 }))),
|
|
1048
|
+
...facts.slice(facts.length - Math.floor((room - 1) / 2)),
|
|
1049
|
+
];
|
|
1050
|
+
const edge = chalk.yellow(BOX_V);
|
|
1051
|
+
return [
|
|
1052
|
+
chalk.yellow(`${BOX_TL}${LINE_H.repeat(inner)}${BOX_TR}`),
|
|
1053
|
+
...[title, ...shown, ...answer].map(line => `${edge}${padAnsi(` ${line}`, inner)}${edge}`),
|
|
1054
|
+
chalk.yellow(`${BOX_BL}${LINE_H.repeat(inner)}${BOX_BR}`),
|
|
1055
|
+
];
|
|
1056
|
+
}
|
|
839
1057
|
makePrompt() {
|
|
840
1058
|
return `${chalk.cyan('❯')} ${chalk.bold(this.config.prompt)} `;
|
|
841
1059
|
}
|
|
842
1060
|
makeContPrompt() {
|
|
843
1061
|
return `${chalk.gray('…')} ${' '.repeat(this.config.prompt.length)} `;
|
|
844
1062
|
}
|
|
1063
|
+
/** 底栏:按键记号后面写它的作用(视图名同启动视图的选项,退出同帮助页),日志页另写换行的开关;按显示宽度截到一行 */
|
|
845
1064
|
renderFooter(width) {
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1065
|
+
const keys = [
|
|
1066
|
+
['^T', t('viewChat')],
|
|
1067
|
+
['^L', t('viewLogs')],
|
|
1068
|
+
['^S', t('viewStatus')],
|
|
1069
|
+
['^G', t('viewHelp')],
|
|
1070
|
+
['Esc', t('footerBack')],
|
|
1071
|
+
['^C', t('helpExit')],
|
|
853
1072
|
];
|
|
1073
|
+
const items = keys.map(([key, text]) => `${chalk.cyan(key)} ${this.localize(text)}`);
|
|
854
1074
|
if (this.view === 'logs') {
|
|
855
|
-
|
|
1075
|
+
const state = this.logWrap ? chalk.green(this.localize(t('wrapOn'))) : chalk.gray(this.localize(t('wrapOff')));
|
|
1076
|
+
items.push(`${chalk.cyan('^W')} ${this.localize(t('footerWrap', { state }))}`);
|
|
856
1077
|
}
|
|
857
|
-
|
|
858
|
-
const left = ` ${items} `;
|
|
859
|
-
return padAnsi(chalk.gray.dim(left), width);
|
|
1078
|
+
return padAnsi(chalk.gray.dim(` ${items.join(chalk.gray(' · '))} `), width);
|
|
860
1079
|
}
|
|
861
1080
|
}
|
|
1081
|
+
// ===== 帮助页 =====
|
|
1082
|
+
/** 帮助页:各节的标题与「按键、作用」行,末尾一节是提示 */
|
|
1083
|
+
const HELP = {
|
|
1084
|
+
sections: [
|
|
1085
|
+
{
|
|
1086
|
+
title: t('helpViews'),
|
|
1087
|
+
rows: [
|
|
1088
|
+
['Ctrl+T', t('helpChat')],
|
|
1089
|
+
['Ctrl+O', t('helpExpand')],
|
|
1090
|
+
['Ctrl+L', t('helpLogs')],
|
|
1091
|
+
['Ctrl+S', t('helpStatus')],
|
|
1092
|
+
['Ctrl+G', t('helpHelp')],
|
|
1093
|
+
['Esc', t('helpBack')],
|
|
1094
|
+
['Ctrl+C', t('helpExit')],
|
|
1095
|
+
],
|
|
1096
|
+
},
|
|
1097
|
+
{
|
|
1098
|
+
title: t('helpScrolling'),
|
|
1099
|
+
rows: [
|
|
1100
|
+
['↑ / ↓', t('helpLine')],
|
|
1101
|
+
['PgUp/Dn', t('helpPage')],
|
|
1102
|
+
['Home', t('helpTop')],
|
|
1103
|
+
['End', t('helpBottom')],
|
|
1104
|
+
['Ctrl+W', t('helpWrap')],
|
|
1105
|
+
],
|
|
1106
|
+
},
|
|
1107
|
+
{
|
|
1108
|
+
title: t('helpEditing'),
|
|
1109
|
+
rows: [
|
|
1110
|
+
['Enter', t('helpSubmit')],
|
|
1111
|
+
['Ctrl+J', t('helpNewline')],
|
|
1112
|
+
['Shift+Ent', t('helpShiftNewline')],
|
|
1113
|
+
['↑ / ↓', t('helpHistory')],
|
|
1114
|
+
],
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
title: t('helpCard'),
|
|
1118
|
+
rows: [
|
|
1119
|
+
['← / →', t('helpChoose')],
|
|
1120
|
+
['Ctrl+Y', t('approve')],
|
|
1121
|
+
['Ctrl+N', t('deny')],
|
|
1122
|
+
],
|
|
1123
|
+
},
|
|
1124
|
+
],
|
|
1125
|
+
tipsTitle: t('helpTips'),
|
|
1126
|
+
tips: [t('tipLogFile'), t('tipRestore'), t('tipCommands'), t('tipKeys')],
|
|
1127
|
+
};
|
|
1128
|
+
/** 卡片结算后在聊天区留的话 */
|
|
1129
|
+
const SETTLED_LINES = {
|
|
1130
|
+
approved: 'settledApproved',
|
|
1131
|
+
denied: 'settledDenied',
|
|
1132
|
+
expired: 'settledExpired',
|
|
1133
|
+
cancelled: 'settledCancelled',
|
|
1134
|
+
};
|
|
1135
|
+
/** 每项事实显示的字数上限,超出的保留首尾 */
|
|
1136
|
+
const CARD_FACT_LIMIT = 1000;
|
|
1137
|
+
/** 卡上事实的标签 */
|
|
1138
|
+
const FACT_LABELS = {
|
|
1139
|
+
subtask: t('factSubtask'),
|
|
1140
|
+
command: t('factCommand'),
|
|
1141
|
+
cwd: t('factCwd'),
|
|
1142
|
+
file: t('factFile'),
|
|
1143
|
+
patch: t('factPatch'),
|
|
1144
|
+
params: t('factParams'),
|
|
1145
|
+
};
|
|
1146
|
+
/**
|
|
1147
|
+
* 卡上的事实:标签(已按读者渲染)与原文。子会话里的审批先写来源链的各级标题(已按读者渲染),以「 › 」相接;标题里的换行与
|
|
1148
|
+
* 别的多行事实一样成续行,缩进到值的那一列,冒充不了带标签的一行
|
|
1149
|
+
*/
|
|
1150
|
+
function cardFacts(facts, chain, labels) {
|
|
1151
|
+
const rows = [];
|
|
1152
|
+
if (chain?.length)
|
|
1153
|
+
rows.push([labels.subtask, chain.join(' › ')]);
|
|
1154
|
+
if (facts.command !== undefined)
|
|
1155
|
+
rows.push([labels.command, facts.command]);
|
|
1156
|
+
if (facts.cwd !== undefined)
|
|
1157
|
+
rows.push([labels.cwd, facts.cwd]);
|
|
1158
|
+
if (facts.diff)
|
|
1159
|
+
rows.push([labels.file, facts.diff.path], [labels.patch, facts.diff.patch]);
|
|
1160
|
+
if (Object.keys(facts.params).length > 0)
|
|
1161
|
+
rows.push([labels.params, JSON.stringify(facts.params)]);
|
|
1162
|
+
return rows;
|
|
1163
|
+
}
|
|
1164
|
+
/** 一项清洗过的事实:按换行拆开、按宽度折行;标签只在第一行,补齐到 labelWidth,续行缩进到值的那一列 */
|
|
1165
|
+
function factLines(label, labelWidth, text, width) {
|
|
1166
|
+
const head = `${padAnsi(chalk.gray(label), labelWidth)} `;
|
|
1167
|
+
const indent = ' '.repeat(labelWidth + 2);
|
|
1168
|
+
return text
|
|
1169
|
+
.replace(/\t/g, ' ')
|
|
1170
|
+
.split('\n')
|
|
1171
|
+
.flatMap(line => wrapByVisibleWidth(line, width - indent.length))
|
|
1172
|
+
.map((segment, i) => (i === 0 ? head : indent) + segment);
|
|
1173
|
+
}
|
|
862
1174
|
// ===== 视觉常量 / 工具 =====
|
|
863
1175
|
const LINE_H = '─';
|
|
864
1176
|
const BOX_TL = '╭';
|
|
@@ -915,6 +1227,32 @@ function clipExact(s, width) {
|
|
|
915
1227
|
* 把单行字符串按可见宽度软折行(CJK 算 2 列,emoji/ANSI 不破坏)。
|
|
916
1228
|
* 返回若干段,每段宽度 ≤ width。空字符串返回 [''] 以保留视觉空行。
|
|
917
1229
|
*/
|
|
1230
|
+
/** 按宽度折行,优先在空格处断开(断处的空白去掉):一行放不下的词(含不带空格的中日韩长句)再按宽度切 */
|
|
1231
|
+
function wrapWords(s, width) {
|
|
1232
|
+
if (width <= 0 || stringWidth(s) <= width)
|
|
1233
|
+
return [s];
|
|
1234
|
+
const lines = [];
|
|
1235
|
+
let line = '';
|
|
1236
|
+
for (const token of s.split(/(\s+)/)) {
|
|
1237
|
+
if (stringWidth(line + token) <= width) {
|
|
1238
|
+
line += token;
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
if (/^\s+$/.test(token)) {
|
|
1242
|
+
lines.push(line);
|
|
1243
|
+
line = '';
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (line.trim())
|
|
1247
|
+
lines.push(line.trimEnd());
|
|
1248
|
+
const pieces = wrapByVisibleWidth(token, width);
|
|
1249
|
+
line = pieces.pop() ?? '';
|
|
1250
|
+
lines.push(...pieces);
|
|
1251
|
+
}
|
|
1252
|
+
if (line)
|
|
1253
|
+
lines.push(line);
|
|
1254
|
+
return lines;
|
|
1255
|
+
}
|
|
918
1256
|
function wrapByVisibleWidth(s, width) {
|
|
919
1257
|
if (width <= 0)
|
|
920
1258
|
return [s];
|