@aalis/plugin-cli 0.13.0 → 0.14.1
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 +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +348 -229
- 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 +20 -14
- 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/index.js
CHANGED
|
@@ -1,29 +1,26 @@
|
|
|
1
1
|
import { stdin as input, stdout as output } from 'node:process';
|
|
2
2
|
import * as readline from 'node:readline';
|
|
3
|
-
import { approvals, sanitizeForCard, } from '@aalis/api-approval';
|
|
3
|
+
import { approvals, clipForCard, sanitizeForCard, } from '@aalis/api-approval';
|
|
4
4
|
import { commands } from '@aalis/api-commands';
|
|
5
5
|
import { unmetDependencies } from '@aalis/api-doctor';
|
|
6
6
|
import { hostConfig } from '@aalis/api-host-config';
|
|
7
7
|
import { hostLogs } from '@aalis/api-host-logs';
|
|
8
|
+
import { i18n, localizeFor } from '@aalis/api-i18n';
|
|
8
9
|
import { persona } from '@aalis/api-persona';
|
|
9
10
|
import { getPlatformAdapters, platform, resolvePlatformBySession, } from '@aalis/api-platform';
|
|
10
|
-
import { sessionManager } from '@aalis/api-session-manager';
|
|
11
|
+
import { isUnfiled, sessionManager } from '@aalis/api-session-manager';
|
|
12
|
+
import { sessionProtocol } from '@aalis/api-session-protocol';
|
|
11
13
|
import { storage } from '@aalis/api-storage';
|
|
12
14
|
import { config, definePlugin, defineService, events, lifecycle, logger, optional, pluginsService, provide, services, } from '@aalis/core';
|
|
13
15
|
import { defineConfig, parseConfig } from '@aalis/schema-config';
|
|
14
|
-
import {
|
|
16
|
+
import { textOf } from '@aalis/schema-i18n';
|
|
15
17
|
import chalk from 'chalk';
|
|
16
18
|
import cliTruncate from 'cli-truncate';
|
|
17
19
|
import stringWidth from 'string-width';
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return `${n} B`;
|
|
23
|
-
if (n < 1024 * 1024)
|
|
24
|
-
return `${(n / 1024).toFixed(1)} KB`;
|
|
25
|
-
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
26
|
-
}
|
|
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';
|
|
27
24
|
// ----- 服务描述符(调用型:业务层就是提供者的形状)-----
|
|
28
25
|
export const cli = defineService('cli');
|
|
29
26
|
// ===== 插件元数据 =====
|
|
@@ -31,38 +28,40 @@ const name = '@aalis/plugin-cli';
|
|
|
31
28
|
const configSchema = defineConfig({
|
|
32
29
|
sessionId: {
|
|
33
30
|
type: 'string',
|
|
34
|
-
label: '
|
|
31
|
+
label: t('sessionIdLabel'),
|
|
35
32
|
default: 'cli-default',
|
|
36
33
|
onInvalid: 'error',
|
|
37
|
-
description: '
|
|
34
|
+
description: t('sessionIdDescription'),
|
|
38
35
|
},
|
|
39
36
|
lastView: {
|
|
40
37
|
type: 'string',
|
|
41
|
-
label: '
|
|
38
|
+
label: t('lastViewLabel'),
|
|
42
39
|
default: 'chat',
|
|
43
|
-
description: '
|
|
40
|
+
description: t('lastViewDescription'),
|
|
44
41
|
},
|
|
45
|
-
prompt: { type: 'string', label: '
|
|
42
|
+
prompt: { type: 'string', label: t('promptLabel'), default: 'You', description: t('promptDescription') },
|
|
46
43
|
maxLogEntries: {
|
|
47
44
|
type: 'number',
|
|
48
|
-
label: '
|
|
45
|
+
label: t('maxLogEntriesLabel'),
|
|
49
46
|
default: 50000,
|
|
50
|
-
description: '
|
|
47
|
+
description: t('maxLogEntriesDescription'),
|
|
51
48
|
},
|
|
52
49
|
startupView: {
|
|
53
50
|
type: 'select',
|
|
54
|
-
label: '
|
|
51
|
+
label: t('startupViewLabel'),
|
|
55
52
|
default: 'last',
|
|
56
|
-
description: '
|
|
53
|
+
description: t('startupViewDescription'),
|
|
57
54
|
options: [
|
|
58
|
-
{ label: '
|
|
59
|
-
{ label: '
|
|
60
|
-
{ label: '
|
|
61
|
-
{ label: '
|
|
62
|
-
{ 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' },
|
|
63
60
|
],
|
|
64
61
|
},
|
|
65
62
|
});
|
|
63
|
+
/** 本地终端身份:物理访问即运维者本人。接审批面、接会话协议都以它 */
|
|
64
|
+
const CONSOLE = { platform: 'cli', userId: 'console' };
|
|
66
65
|
// ===== 插件入口 =====
|
|
67
66
|
// platform 既被本插件提供又被状态页枚举,只能是可选依赖:required 会让激活等一个由自己提供的服务。
|
|
68
67
|
const uses = {
|
|
@@ -85,10 +84,14 @@ const uses = {
|
|
|
85
84
|
commands: optional(commands),
|
|
86
85
|
/** 发出入站之前给配置的会话建档,创建来源写 cli 审批面的登记名 */
|
|
87
86
|
sessionManager: optional(sessionManager),
|
|
87
|
+
/** 界面文字按全局层的语言显示;缺席时按原文 */
|
|
88
|
+
i18n: optional(i18n),
|
|
89
|
+
/** 聊天区她的输出取会话协议里本会话的条目;缺席时只写整条回复 */
|
|
90
|
+
sessionProtocol: optional(sessionProtocol),
|
|
88
91
|
};
|
|
89
92
|
export default definePlugin({
|
|
90
93
|
name,
|
|
91
|
-
displayName: '
|
|
94
|
+
displayName: t('displayName'),
|
|
92
95
|
subsystem: 'platform',
|
|
93
96
|
configSchema,
|
|
94
97
|
provides: [cli, platform],
|
|
@@ -108,6 +111,17 @@ function startCli(caps) {
|
|
|
108
111
|
const sessionId = cliConfig.sessionId;
|
|
109
112
|
const approvalSurface = caps.approvals.use.registerSurface('cli');
|
|
110
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 });
|
|
111
125
|
const adapter = {
|
|
112
126
|
adapterName: 'CLI',
|
|
113
127
|
platform: 'cli',
|
|
@@ -133,69 +147,57 @@ function startCli(caps) {
|
|
|
133
147
|
};
|
|
134
148
|
caps.provide.use(cli, cliService);
|
|
135
149
|
// 附件:终端就是这个会话的平台,显示不了图片本身,写一行说明(类型、名称、大小、本机路径),写出即算送达并照常归档。
|
|
136
|
-
//
|
|
150
|
+
// 路径在支持超链接的终端里可以点开。类型的显示名取本插件字典(归档协议的类别占位符不当显示名)
|
|
137
151
|
const attachmentStorage = caps.storage.use;
|
|
138
|
-
const KIND_LABELS = {
|
|
139
|
-
image: AttachmentRefKind.Image,
|
|
140
|
-
audio: AttachmentRefKind.Audio,
|
|
141
|
-
video: AttachmentRefKind.Video,
|
|
142
|
-
file: AttachmentRefKind.File,
|
|
143
|
-
};
|
|
144
152
|
async function attachmentLine(att) {
|
|
145
|
-
const label = `[${
|
|
153
|
+
const label = `[${localize(t('attachmentKind', { kind: att.kind }))}]`;
|
|
146
154
|
if (/^https?:\/\//i.test(att.data))
|
|
147
155
|
return `${label} ${att.name ?? ''} ${att.data}`.replace(/\s+/g, ' ').trim();
|
|
148
156
|
if (att.data.startsWith('data:') && att.data.includes(';base64,')) {
|
|
149
157
|
const bytes = Math.floor(((att.data.length - att.data.indexOf(',') - 1) * 3) / 4);
|
|
150
|
-
return `${label} ${att.name ?? '
|
|
158
|
+
return `${label} ${att.name ?? localize(t('inlineData'))} · ${localize(t('size', { bytes }))}`;
|
|
151
159
|
}
|
|
152
160
|
let path = att.data;
|
|
153
161
|
let size = '';
|
|
154
162
|
try {
|
|
155
163
|
path = await attachmentStorage.resolveLocalPath(att.data, 'read');
|
|
156
|
-
size =
|
|
164
|
+
size = localize(t('size', { bytes: (await attachmentStorage.stat(att.data)).size }));
|
|
157
165
|
}
|
|
158
166
|
catch {
|
|
159
167
|
// 取不到本机路径时照写存储地址
|
|
160
168
|
}
|
|
161
169
|
return [`${label} ${att.name ?? path.split('/').pop() ?? ''}`, size, path].filter(Boolean).join(' · ');
|
|
162
170
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
catch (err) {
|
|
174
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
175
|
-
}
|
|
176
|
-
})();
|
|
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));
|
|
177
181
|
}
|
|
178
182
|
events.use.on('outbound:message', msg => {
|
|
179
183
|
if (msg.sessionId !== sessionId)
|
|
180
184
|
return;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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));
|
|
186
193
|
return;
|
|
187
194
|
}
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
:
|
|
193
|
-
|
|
194
|
-
});
|
|
195
|
-
events.use.on('outbound:stream', chunk => {
|
|
196
|
-
if (chunk.sessionId !== sessionId)
|
|
197
|
-
return;
|
|
198
|
-
tui?.applyStreamChunk(chunk);
|
|
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) }));
|
|
199
201
|
});
|
|
200
202
|
// 日志从激活起订阅:先补发最近 2000 条,之后实时接着送,seq 连续。界面起来之前攒着,起来之后交给它
|
|
201
203
|
const earlyLogs = [];
|
|
@@ -209,7 +211,7 @@ function startCli(caps) {
|
|
|
209
211
|
logger.use.info('stdin/stdout 非 TTY,CLI 界面未启动');
|
|
210
212
|
return;
|
|
211
213
|
}
|
|
212
|
-
const started = new CliTui(caps, cliConfig, sessionId, approvalSurface, earlyLogs);
|
|
214
|
+
const started = new CliTui(caps, cliConfig, sessionId, approvalSurface, localize, earlyLogs);
|
|
213
215
|
onLog = entry => started.appendLog(entry);
|
|
214
216
|
tui = started;
|
|
215
217
|
tui.start();
|
|
@@ -223,6 +225,7 @@ class CliTui {
|
|
|
223
225
|
config;
|
|
224
226
|
sessionId;
|
|
225
227
|
approvalSurface;
|
|
228
|
+
localize;
|
|
226
229
|
view;
|
|
227
230
|
previousView = 'chat';
|
|
228
231
|
/** 非 chat 视图期间进入聊天区的条数(确认提示、回复);header 的 CHAT 页签带计数,切回即清 */
|
|
@@ -236,15 +239,17 @@ class CliTui {
|
|
|
236
239
|
delivering = Promise.resolve();
|
|
237
240
|
/** 已告警过配置的会话 id 由别的平台适配器认领 */
|
|
238
241
|
warnedForeign = false;
|
|
239
|
-
|
|
242
|
+
log = new ChatLog();
|
|
243
|
+
/** 连续的工具调用全部展开(Ctrl+O);不存配置,重启回到收起 */
|
|
244
|
+
expanded = false;
|
|
245
|
+
/** 经会话协议订阅终端的会话,条目进聊天区 */
|
|
246
|
+
session;
|
|
247
|
+
unfollow;
|
|
240
248
|
logLines;
|
|
241
249
|
logScroll = 0;
|
|
242
250
|
logWrap = false;
|
|
243
251
|
statusScroll = 0;
|
|
244
252
|
helpScroll = 0;
|
|
245
|
-
// 流式状态:正在生成的 assistant 回复,其行由 ChatBuffer 维护为末尾一段
|
|
246
|
-
streamingContent = '';
|
|
247
|
-
streamedRecently = false;
|
|
248
253
|
renderQueued = false;
|
|
249
254
|
closing = false;
|
|
250
255
|
restoreOnExit = () => restoreTerminalState();
|
|
@@ -253,22 +258,28 @@ class CliTui {
|
|
|
253
258
|
/** 卡上选中的应答。推出时不选:正在打字时按下的回车不会落到卡上 */
|
|
254
259
|
choice;
|
|
255
260
|
attendance;
|
|
256
|
-
constructor(caps, config, sessionId, approvalSurface, initialEntries = []) {
|
|
261
|
+
constructor(caps, config, sessionId, approvalSurface, localize, initialEntries = []) {
|
|
257
262
|
this.caps = caps;
|
|
258
263
|
this.config = config;
|
|
259
264
|
this.sessionId = sessionId;
|
|
260
265
|
this.approvalSurface = approvalSurface;
|
|
266
|
+
this.localize = localize;
|
|
261
267
|
this.view = config.startupView === 'last' ? config.lastView : config.startupView;
|
|
262
268
|
// 日志由插件入口的订阅送来:界面起来之前的这一批,之后经 appendLog 一条条接着
|
|
263
269
|
this.logLines = initialEntries.slice();
|
|
264
|
-
// 以本地终端身份接入审批面(与输入消息的 userId
|
|
265
|
-
this.attendance = this.approvalSurface.attend(
|
|
270
|
+
// 以本地终端身份接入审批面(与输入消息的 userId 相同)。有按键才上报在场。卡上的文字按全局层的语言渲染,事实原样
|
|
271
|
+
this.attendance = this.approvalSurface.attend(CONSOLE, {
|
|
266
272
|
requested: approval => {
|
|
267
273
|
this.cards.push(approval);
|
|
268
274
|
this.queueRender();
|
|
269
275
|
},
|
|
270
|
-
settled: (approvalId, { state }) => this.closeCard(approvalId, SETTLED_LINES[state]),
|
|
276
|
+
settled: (approvalId, { state }) => this.closeCard(approvalId, tool => t(SETTLED_LINES[state], { tool })),
|
|
271
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);
|
|
272
283
|
}
|
|
273
284
|
isRunning() {
|
|
274
285
|
return this.running;
|
|
@@ -290,13 +301,17 @@ class CliTui {
|
|
|
290
301
|
input.setRawMode(true);
|
|
291
302
|
input.resume();
|
|
292
303
|
process.once('exit', this.restoreOnExit);
|
|
293
|
-
const
|
|
294
|
-
this.
|
|
295
|
-
chalk.gray(this.formatCont()) + chalk.gray(`欢迎使用 ${assistantName}。按 ${chalk.cyan('Ctrl+G')} 查看快捷键。`),
|
|
296
|
-
]);
|
|
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))]);
|
|
297
306
|
void this.pointToUnready();
|
|
307
|
+
// 她的输出取会话协议里本会话的条目:会话服务在就接上,换提供者时断开旧的、接上新的
|
|
308
|
+
this.unfollow = this.caps.sessionProtocol.follow(service => this.session.attach(service));
|
|
298
309
|
input.on('keypress', this.handleKeypress);
|
|
299
310
|
output.on('resize', this.queueRender);
|
|
311
|
+
// 设置变了(全局层的语言、终端会话的人设,在宿主配置文档或会话设置里):界面立即按新的设置重绘。聊天区的本地行写进时定住,
|
|
312
|
+
// 她的回复与工具行随当前设置重绘
|
|
313
|
+
this.caps.events.use.on('session-settings:changed', this.queueRender);
|
|
314
|
+
this.caps.events.use.on('host-config:changed', this.queueRender);
|
|
300
315
|
this.caps.lifecycle.use.onDispose(() => this.stop());
|
|
301
316
|
this.render();
|
|
302
317
|
}
|
|
@@ -314,10 +329,10 @@ class CliTui {
|
|
|
314
329
|
const count = status.filter(p => p.state === 'error').length + lacking.length;
|
|
315
330
|
if (count === 0)
|
|
316
331
|
return;
|
|
317
|
-
const
|
|
318
|
-
?
|
|
319
|
-
:
|
|
320
|
-
this.
|
|
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))]);
|
|
321
336
|
this.queueRender();
|
|
322
337
|
}
|
|
323
338
|
stop() {
|
|
@@ -325,6 +340,7 @@ class CliTui {
|
|
|
325
340
|
return;
|
|
326
341
|
this.closing = true;
|
|
327
342
|
this.running = false;
|
|
343
|
+
this.unfollow?.();
|
|
328
344
|
input.off('keypress', this.handleKeypress);
|
|
329
345
|
output.off('resize', this.queueRender);
|
|
330
346
|
process.off('exit', this.restoreOnExit);
|
|
@@ -344,59 +360,30 @@ class CliTui {
|
|
|
344
360
|
if (this.view === 'logs')
|
|
345
361
|
this.queueRender();
|
|
346
362
|
}
|
|
363
|
+
/** 写一段她名下的本地行(整条回复、指令回复与系统消息、附件说明) */
|
|
347
364
|
pushAssistant(content) {
|
|
348
365
|
if (!content.trim())
|
|
349
366
|
return;
|
|
350
|
-
this.
|
|
367
|
+
this.log.append(this.formatAssistantBlock(content));
|
|
351
368
|
if (this.view !== 'chat')
|
|
352
369
|
this.unreadChat++;
|
|
353
370
|
this.queueRender();
|
|
354
371
|
}
|
|
355
|
-
/**
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
consumeStreamedSend() {
|
|
359
|
-
if (this.streamedRecently) {
|
|
360
|
-
this.streamedRecently = false;
|
|
361
|
-
return true;
|
|
362
|
-
}
|
|
363
|
-
return false;
|
|
372
|
+
/** 她回复的正文由条目画:订着终端的会话(订阅在途时等它落定) */
|
|
373
|
+
drawsReplies() {
|
|
374
|
+
return this.session.drawsReplies();
|
|
364
375
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
this.chat.endStreaming();
|
|
371
|
-
this.streamingContent = '';
|
|
372
|
-
if (this.view === 'chat')
|
|
373
|
-
this.queueRender();
|
|
374
|
-
}
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
if (!chunk.contentDelta)
|
|
378
|
-
return;
|
|
379
|
-
// 首个块:开一个新的 assistant 条目;上一轮遗留的「已流式」标记一并作废
|
|
380
|
-
if (!this.chat.streaming) {
|
|
381
|
-
this.streamingContent = '';
|
|
382
|
-
this.streamedRecently = false;
|
|
383
|
-
if (this.view !== 'chat') {
|
|
384
|
-
this.unreadChat++;
|
|
385
|
-
this.queueRender();
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
this.streamingContent += chunk.contentDelta;
|
|
389
|
-
// 根据累积内容重建该消息占据的行
|
|
390
|
-
this.chat.replaceStreamingBlock(this.formatAssistantBlock(this.streamingContent));
|
|
391
|
-
if (this.view === 'chat')
|
|
392
|
-
this.queueRender();
|
|
376
|
+
/** 条目来了:有字的回复刚出现、又不在聊天页时计一条未读 */
|
|
377
|
+
timelineChanged(fresh) {
|
|
378
|
+
if (fresh && this.view !== 'chat')
|
|
379
|
+
this.unreadChat++;
|
|
380
|
+
this.queueRender();
|
|
393
381
|
}
|
|
394
|
-
/**
|
|
382
|
+
/** 终端会话生效的人设的名字:人设服务按这个会话取卡,与回合同一口径;没有人设服务或卡没取名时为 undefined */
|
|
395
383
|
personaName() {
|
|
396
|
-
|
|
397
|
-
? this.caps.
|
|
384
|
+
return this.caps.persona.present
|
|
385
|
+
? this.caps.persona.use.getPersonaName({ sessionId: this.sessionId, platform: 'cli' }) || undefined
|
|
398
386
|
: undefined;
|
|
399
|
-
return this.caps.persona.present ? this.caps.persona.use.getPersonaName({ persona: config?.persona }) : undefined;
|
|
400
387
|
}
|
|
401
388
|
/** 所有标签对齐到同一列宽,确保 │ 竖线垂直对齐 */
|
|
402
389
|
labelCol() {
|
|
@@ -450,6 +437,11 @@ class CliTui {
|
|
|
450
437
|
this.switchView('help');
|
|
451
438
|
return;
|
|
452
439
|
}
|
|
440
|
+
if (key.ctrl && key.name === 'o') {
|
|
441
|
+
this.expanded = !this.expanded;
|
|
442
|
+
this.queueRender();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
453
445
|
if (key.name === 'escape') {
|
|
454
446
|
this.switchView(this.previousView);
|
|
455
447
|
return;
|
|
@@ -556,9 +548,9 @@ class CliTui {
|
|
|
556
548
|
const card = this.cards[0];
|
|
557
549
|
const reply = this.attendance.respond({ approvalId: card.id, decision, contentHash: card.contentHash });
|
|
558
550
|
if (!reply.ok)
|
|
559
|
-
this.closeCard(card.id,
|
|
551
|
+
this.closeCard(card.id, tool => t('answerRefused', { tool, reason: reply.message }));
|
|
560
552
|
}
|
|
561
|
-
/**
|
|
553
|
+
/** 审批结算了:卡片关掉,聊天区留一行(line 按工具名给出这一行) */
|
|
562
554
|
closeCard(approvalId, line) {
|
|
563
555
|
const index = this.cards.findIndex(card => card.id === approvalId);
|
|
564
556
|
if (index < 0)
|
|
@@ -566,7 +558,7 @@ class CliTui {
|
|
|
566
558
|
const [card] = this.cards.splice(index, 1);
|
|
567
559
|
if (index === 0)
|
|
568
560
|
this.choice = undefined;
|
|
569
|
-
this.
|
|
561
|
+
this.log.append([`${this.formatHead(chalk.yellow('◆ System'))}${this.localize(line(sanitizeForCard(card.tool)))}`]);
|
|
570
562
|
if (this.view !== 'chat')
|
|
571
563
|
this.unreadChat++;
|
|
572
564
|
this.queueRender();
|
|
@@ -622,7 +614,7 @@ class CliTui {
|
|
|
622
614
|
this.history.shift();
|
|
623
615
|
const firstHead = this.formatHead(chalk.cyan(`❯ ${this.config.prompt}`));
|
|
624
616
|
const contHead = this.formatCont();
|
|
625
|
-
this.
|
|
617
|
+
this.log.append(text.split('\n').map((line, i) => (i === 0 ? firstHead : contHead) + line));
|
|
626
618
|
// 指令解析已统一到全局 inbound:command 相位(plugin-commands)。
|
|
627
619
|
// 适配器不再内联解析:未注册命令被放行为普通消息进入 agent,命中命令
|
|
628
620
|
// 由该相位执行并经 outbound:message 回送渲染到 TUI,与 onebot/webui 一致。
|
|
@@ -642,10 +634,12 @@ class CliTui {
|
|
|
642
634
|
async deliver(text) {
|
|
643
635
|
const sm = this.caps.sessionManager.present ? this.caps.sessionManager.use : undefined;
|
|
644
636
|
const session = sm?.getSession(this.sessionId);
|
|
645
|
-
const unfiled = !session || (
|
|
637
|
+
const unfiled = !session || isUnfiled(session);
|
|
646
638
|
if (sm && unfiled && !(await this.claimedElsewhere())) {
|
|
647
639
|
await sm.ensureSession(this.sessionId, {}, { createdFrom: this.approvalSurface.name });
|
|
648
640
|
}
|
|
641
|
+
// 订上这个会话再发:这一轮的条目从头画进聊天区
|
|
642
|
+
await this.session.ensureSubscribed();
|
|
649
643
|
await this.caps.events.use.emit('inbound:message', {
|
|
650
644
|
content: text,
|
|
651
645
|
sessionId: this.sessionId,
|
|
@@ -725,17 +719,21 @@ class CliTui {
|
|
|
725
719
|
return;
|
|
726
720
|
const width = Math.max(40, output.columns || 100);
|
|
727
721
|
const height = Math.max(14, output.rows || 30);
|
|
728
|
-
//
|
|
722
|
+
// 正文下面的框:有审批卡时是卡片;chat 视图是输入框,可变高度(多行输入):内容行数 + 上下边框,最多 8 行内容;
|
|
723
|
+
// 别的视图是一行视图说明(框高 3 行),在正文之后算,滚动值取正文钳制过的
|
|
729
724
|
const box = this.cards.length > 0
|
|
730
725
|
? this.renderCard(width, height)
|
|
731
|
-
: this.
|
|
726
|
+
: this.view === 'chat'
|
|
727
|
+
? this.renderInputBox(width, this.computeInputDisplayLines(width))
|
|
728
|
+
: undefined;
|
|
732
729
|
// 布局:header(1) + sep(1) + body + box + footer(1)
|
|
733
|
-
const bodyHeight = Math.max(3, height - 3 - box
|
|
730
|
+
const bodyHeight = Math.max(3, height - 3 - (box?.length ?? 3));
|
|
731
|
+
const body = this.renderBody(width, bodyHeight);
|
|
734
732
|
const out = [];
|
|
735
733
|
out.push(this.renderHeader(width));
|
|
736
734
|
out.push(chalk.gray(LINE_H.repeat(width)));
|
|
737
|
-
out.push(...
|
|
738
|
-
out.push(...box);
|
|
735
|
+
out.push(...body);
|
|
736
|
+
out.push(...(box ?? this.renderInputBox(width, [this.viewLine(width)])));
|
|
739
737
|
out.push(this.renderFooter(width));
|
|
740
738
|
output.write('\x1b[?25l\x1b[H');
|
|
741
739
|
output.write(out.map(line => clearLine(line, width)).join('\n'));
|
|
@@ -751,7 +749,8 @@ class CliTui {
|
|
|
751
749
|
}
|
|
752
750
|
}
|
|
753
751
|
renderHeader(width) {
|
|
754
|
-
|
|
752
|
+
// 卡没取名时写 -,同状态页
|
|
753
|
+
const persona = this.personaName() ?? '-';
|
|
755
754
|
const left = ` ${chalk.bold.magenta('●')} ${chalk.bold('Aalis')} ${chalk.gray('·')} ${chalk.cyan(persona)} `;
|
|
756
755
|
const tabs = ['chat', 'logs', 'status', 'help']
|
|
757
756
|
.map(v => {
|
|
@@ -804,17 +803,41 @@ class CliTui {
|
|
|
804
803
|
if (this.view === 'status')
|
|
805
804
|
return this.getStatusViewLines();
|
|
806
805
|
if (this.view === 'help')
|
|
807
|
-
return this.getHelpViewLines();
|
|
806
|
+
return this.getHelpViewLines(width);
|
|
808
807
|
return this.getChatViewLines(width);
|
|
809
808
|
}
|
|
810
809
|
getChatViewLines(width) {
|
|
811
|
-
const lines = [];
|
|
812
810
|
const inner = width - 2; // 缩进 2 列
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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)))];
|
|
816
827
|
}
|
|
817
|
-
|
|
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))}`;
|
|
818
841
|
}
|
|
819
842
|
/** 日志视图的增量格式化缓存。logLines 只追加、从不改写,因此已格式化的行
|
|
820
843
|
* 可以按 (width, wrap) 为键永久复用——每帧只格式化"上次之后新增"的条目,
|
|
@@ -893,21 +916,28 @@ class CliTui {
|
|
|
893
916
|
const adapter = getPlatformAdapters(this.caps.platform).find(a => a.platform === 'cli');
|
|
894
917
|
const connections = adapter?.getConnections?.() ?? [];
|
|
895
918
|
const persona = this.personaName() ?? '-';
|
|
896
|
-
const
|
|
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}`);
|
|
897
927
|
const kv = (k, v) => ` ${chalk.gray(k.padEnd(14))} ${v}`;
|
|
898
928
|
const out = [];
|
|
899
|
-
out.push(sec(
|
|
929
|
+
out.push(sec(text.runtime));
|
|
900
930
|
out.push(kv('persona', persona));
|
|
901
931
|
out.push(kv('cli session', this.sessionId));
|
|
902
|
-
out.push(kv('log buffer',
|
|
932
|
+
out.push(kv('log buffer', text.logs));
|
|
903
933
|
out.push('');
|
|
904
|
-
out.push(sec(
|
|
934
|
+
out.push(sec(text.services));
|
|
905
935
|
for (const s of serviceNames)
|
|
906
936
|
out.push(` ${chalk.green('●')} ${s}`);
|
|
907
937
|
out.push('');
|
|
908
|
-
out.push(sec(
|
|
938
|
+
out.push(sec(text.connections));
|
|
909
939
|
if (connections.length === 0)
|
|
910
|
-
out.push(` ${chalk.gray(
|
|
940
|
+
out.push(` ${chalk.gray(text.none)}`);
|
|
911
941
|
else
|
|
912
942
|
for (const c of connections) {
|
|
913
943
|
const dot = c.status === 'online' ? chalk.green('●') : c.status === 'connecting' ? chalk.yellow('●') : chalk.red('●');
|
|
@@ -915,55 +945,34 @@ class CliTui {
|
|
|
915
945
|
}
|
|
916
946
|
return out.map(l => (l.length === 0 ? '' : ` ${l}`));
|
|
917
947
|
}
|
|
918
|
-
getHelpViewLines() {
|
|
919
|
-
const
|
|
920
|
-
|
|
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);
|
|
921
957
|
const out = [];
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
out.push(
|
|
925
|
-
out
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
out.push('');
|
|
937
|
-
out.push(sec('编辑'));
|
|
938
|
-
out.push(row('Enter', '提交输入'));
|
|
939
|
-
out.push(row('Ctrl+J', '插入换行(macOS Ctrl+Enter)'));
|
|
940
|
-
out.push(row('Shift+Ent', '插入换行(如终端支持)'));
|
|
941
|
-
out.push(row('↑ / ↓', '历史记录回溯(chat 视图)'));
|
|
942
|
-
out.push('');
|
|
943
|
-
out.push(sec('审批卡(卡在时打字与回车不算应答,也不进输入框)'));
|
|
944
|
-
out.push(row('← / →', '选批准 / 拒绝,Enter 确定'));
|
|
945
|
-
out.push(row('Ctrl+Y', '批准'));
|
|
946
|
-
out.push(row('Ctrl+N', '拒绝'));
|
|
947
|
-
out.push('');
|
|
948
|
-
out.push(sec('提示'));
|
|
949
|
-
out.push(` ${chalk.gray('• 完整日志写入家目录的 logs/latest.log,每次启动时上一轮改名为 latest.1.log,保留 5 份;跨重启持续跟随用 tail -F。')}`);
|
|
950
|
-
out.push(` ${chalk.gray('• 退出时自动恢复原终端内容(alternate screen)。')}`);
|
|
951
|
-
out.push(` ${chalk.gray('• 输入以 / 开头会按命令解析,否则发给 agent。')}`);
|
|
952
|
-
out.push(` ${chalk.gray('• ↑/↓、PgUp/PgDn、Home/End 滚动 Logs / Status / Help;选择 / 复制文本直接用终端原生操作。')}`);
|
|
953
|
-
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 })));
|
|
954
972
|
}
|
|
955
973
|
/** 计算输入框的多行内容(不含边框、不含 padding) */
|
|
956
974
|
computeInputDisplayLines(width) {
|
|
957
975
|
const inner = width - 2;
|
|
958
|
-
if (this.view !== 'chat') {
|
|
959
|
-
const total = this.view === 'logs'
|
|
960
|
-
? this.logLines.length
|
|
961
|
-
: this.view === 'status'
|
|
962
|
-
? this.getStatusViewLines().length
|
|
963
|
-
: this.getHelpViewLines().length;
|
|
964
|
-
const scroll = this.view === 'logs' ? this.logScroll : this.view === 'status' ? this.statusScroll : this.helpScroll;
|
|
965
|
-
return [chalk.gray(`${this.view} · ${total} 行 · scroll ${scroll} · 按 Ctrl+T 回到聊天`)];
|
|
966
|
-
}
|
|
967
976
|
const inputLines = this.inputLine.split('\n');
|
|
968
977
|
const display = [];
|
|
969
978
|
const promptLen = visibleLen(this.makePrompt());
|
|
@@ -996,30 +1005,52 @@ class CliTui {
|
|
|
996
1005
|
return [top, ...mid, bot];
|
|
997
1006
|
}
|
|
998
1007
|
/**
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1008
|
+
* 审批卡:标题行、事实、应答项,上下边框。子会话里的审批在事实之前多一行「子任务」,写根会话以下的各级标题。卡片最多高到
|
|
1009
|
+
* 正文只剩 3 行:事实放不下时保留首尾几行,写明省略了多少行。事实显示前经 clipForCard 清洗,太长的保留首尾、写明省略了多少字
|
|
1001
1010
|
*/
|
|
1002
1011
|
renderCard(width, height) {
|
|
1003
1012
|
const card = this.cards[0];
|
|
1004
1013
|
const inner = width - 2;
|
|
1005
|
-
const
|
|
1006
|
-
const
|
|
1007
|
-
const
|
|
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))];
|
|
1008
1041
|
// 高度:header、sep、footer 各一行,正文至少 3 行,卡片的上下边框、标题行与应答行
|
|
1009
|
-
const room = height -
|
|
1042
|
+
const room = height - 9 - answer.length;
|
|
1010
1043
|
const shown = facts.length <= room
|
|
1011
1044
|
? facts
|
|
1012
1045
|
: [
|
|
1013
1046
|
...facts.slice(0, Math.ceil((room - 1) / 2)),
|
|
1014
|
-
chalk.gray(
|
|
1047
|
+
chalk.gray(this.localize(t('linesOmitted', { count: facts.length - room + 1 }))),
|
|
1015
1048
|
...facts.slice(facts.length - Math.floor((room - 1) / 2)),
|
|
1016
1049
|
];
|
|
1017
|
-
const option = (decision, label) => this.choice === decision ? chalk.inverse(`❯ ${label}`) : ` ${label}`;
|
|
1018
|
-
const options = `${option('approve', '批准')} ${option('deny', '拒绝')} ${chalk.gray('←/→ 选择 · Enter 确定 · Ctrl+Y 批准 · Ctrl+N 拒绝')}`;
|
|
1019
1050
|
const edge = chalk.yellow(BOX_V);
|
|
1020
1051
|
return [
|
|
1021
1052
|
chalk.yellow(`${BOX_TL}${LINE_H.repeat(inner)}${BOX_TR}`),
|
|
1022
|
-
...[title, ...shown,
|
|
1053
|
+
...[title, ...shown, ...answer].map(line => `${edge}${padAnsi(` ${line}`, inner)}${edge}`),
|
|
1023
1054
|
chalk.yellow(`${BOX_BL}${LINE_H.repeat(inner)}${BOX_BR}`),
|
|
1024
1055
|
];
|
|
1025
1056
|
}
|
|
@@ -1029,50 +1060,112 @@ class CliTui {
|
|
|
1029
1060
|
makeContPrompt() {
|
|
1030
1061
|
return `${chalk.gray('…')} ${' '.repeat(this.config.prompt.length)} `;
|
|
1031
1062
|
}
|
|
1063
|
+
/** 底栏:按键记号后面写它的作用(视图名同启动视图的选项,退出同帮助页),日志页另写换行的开关;按显示宽度截到一行 */
|
|
1032
1064
|
renderFooter(width) {
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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')],
|
|
1040
1072
|
];
|
|
1073
|
+
const items = keys.map(([key, text]) => `${chalk.cyan(key)} ${this.localize(text)}`);
|
|
1041
1074
|
if (this.view === 'logs') {
|
|
1042
|
-
|
|
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 }))}`);
|
|
1043
1077
|
}
|
|
1044
|
-
|
|
1045
|
-
const left = ` ${items} `;
|
|
1046
|
-
return padAnsi(chalk.gray.dim(left), width);
|
|
1078
|
+
return padAnsi(chalk.gray.dim(` ${items.join(chalk.gray(' · '))} `), width);
|
|
1047
1079
|
}
|
|
1048
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
|
+
};
|
|
1049
1128
|
/** 卡片结算后在聊天区留的话 */
|
|
1050
1129
|
const SETTLED_LINES = {
|
|
1051
|
-
approved: '
|
|
1052
|
-
denied: '
|
|
1053
|
-
expired: '
|
|
1054
|
-
cancelled: '
|
|
1130
|
+
approved: 'settledApproved',
|
|
1131
|
+
denied: 'settledDenied',
|
|
1132
|
+
expired: 'settledExpired',
|
|
1133
|
+
cancelled: 'settledCancelled',
|
|
1055
1134
|
};
|
|
1056
1135
|
/** 每项事实显示的字数上限,超出的保留首尾 */
|
|
1057
1136
|
const CARD_FACT_LIMIT = 1000;
|
|
1058
|
-
/**
|
|
1059
|
-
|
|
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) {
|
|
1060
1151
|
const rows = [];
|
|
1152
|
+
if (chain?.length)
|
|
1153
|
+
rows.push([labels.subtask, chain.join(' › ')]);
|
|
1061
1154
|
if (facts.command !== undefined)
|
|
1062
|
-
rows.push([
|
|
1155
|
+
rows.push([labels.command, facts.command]);
|
|
1063
1156
|
if (facts.cwd !== undefined)
|
|
1064
|
-
rows.push([
|
|
1157
|
+
rows.push([labels.cwd, facts.cwd]);
|
|
1065
1158
|
if (facts.diff)
|
|
1066
|
-
rows.push([
|
|
1159
|
+
rows.push([labels.file, facts.diff.path], [labels.patch, facts.diff.patch]);
|
|
1067
1160
|
if (Object.keys(facts.params).length > 0)
|
|
1068
|
-
rows.push([
|
|
1161
|
+
rows.push([labels.params, JSON.stringify(facts.params)]);
|
|
1069
1162
|
return rows;
|
|
1070
1163
|
}
|
|
1071
|
-
/**
|
|
1072
|
-
function factLines(label, text, width) {
|
|
1073
|
-
const head = `${chalk.gray(label)} `;
|
|
1074
|
-
const indent = ' '.repeat(
|
|
1075
|
-
return
|
|
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
|
|
1076
1169
|
.replace(/\t/g, ' ')
|
|
1077
1170
|
.split('\n')
|
|
1078
1171
|
.flatMap(line => wrapByVisibleWidth(line, width - indent.length))
|
|
@@ -1134,6 +1227,32 @@ function clipExact(s, width) {
|
|
|
1134
1227
|
* 把单行字符串按可见宽度软折行(CJK 算 2 列,emoji/ANSI 不破坏)。
|
|
1135
1228
|
* 返回若干段,每段宽度 ≤ width。空字符串返回 [''] 以保留视觉空行。
|
|
1136
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
|
+
}
|
|
1137
1256
|
function wrapByVisibleWidth(s, width) {
|
|
1138
1257
|
if (width <= 0)
|
|
1139
1258
|
return [s];
|