@aalis/plugin-cli 0.13.0 → 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/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 { AttachmentRefKind, } from '@aalis/schema-message';
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 { ChatBuffer } from './chat-buffer.js';
19
- /** 附件说明里的大小 */
20
- function formatBytes(n) {
21
- if (n < 1024)
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: '默认会话 ID',
31
+ label: t('sessionIdLabel'),
35
32
  default: 'cli-default',
36
33
  onInvalid: 'error',
37
- description: 'CLI 聊天所在的会话标识。',
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: '运行时自动写回;startupView=last 时启动恢复到这里记录的视图。',
40
+ description: t('lastViewDescription'),
44
41
  },
45
- prompt: { type: 'string', label: '提示符', default: 'You', description: '命令行输入提示符前缀' },
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: '日志页内存驻留的最大条数,超出后从最旧成块丢弃;完整日志始终在家目录的 logs/latest.log',
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: 'CLI 接管终端后的默认视图。last 表示恢复上次视图',
53
+ description: t('startupViewDescription'),
57
54
  options: [
58
- { label: '恢复上次', value: 'last' },
59
- { label: '聊天', value: 'chat' },
60
- { label: '日志', value: 'logs' },
61
- { label: '状态', value: 'status' },
62
- { label: '帮助', value: 'help' },
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: 'CLI 终端',
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 = `[${KIND_LABELS[att.kind] ?? '附件'}]`;
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 ?? '内联数据'} · ${formatBytes(bytes)}`;
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 = formatBytes((await attachmentStorage.stat(att.data)).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
- function sendAttachments(msg) {
164
- const attachments = (msg.attachments ?? []).map(att => ({ ...att }));
165
- // 同步认领:发起方 await emit 之后就要能等到它
166
- msg.delivery = (async () => {
167
- try {
168
- for (const att of attachments)
169
- adapter.sendMessage(msg.sessionId, await attachmentLine(att));
170
- void events.use.emit('outbound:delivered', { ...msg, attachments, delivery: undefined });
171
- return { ok: true };
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
- if (msg.attachments?.length) {
182
- sendAttachments(msg);
183
- // 附件消息里的文字照常显示;它不是流式正文的整条重复,不消耗流式标记
184
- if (msg.content.trim())
185
- adapter.sendMessage(msg.sessionId, msg.content);
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
- // 纯文本同样写出即算送达,同步认领回执(作品通知等要等它)。同一轮已流式输出时,agent 的整条回复是重复、
189
- // 正文早已写出;系统/指令消息不是流式正文,照常显示
190
- const written = msg.source === 'agent' && tui?.consumeStreamedSend()
191
- ? Promise.resolve()
192
- : adapter.sendMessage(msg.sessionId, msg.content);
193
- msg.delivery = written.then(() => ({ ok: true }), err => ({ ok: false, error: err instanceof Error ? err.message : String(err) }));
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
- chat = new ChatBuffer();
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({ platform: 'cli', userId: 'console' }, {
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 assistantName = this.personaName() ?? 'Aalis';
294
- this.chat.append([
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 how = (this.caps.commands.present ? this.caps.commands.use.has('doctor') : undefined)
318
- ? `输入 ${chalk.cyan('/doctor')} 查看原因与修法`
319
- : `按 ${chalk.cyan('Ctrl+L')} 看日志`;
320
- this.chat.append([chalk.gray(this.formatCont()) + chalk.yellow(`有 ${count} 个插件没起来,${how}`)]);
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.chat.append(this.formatAssistantBlock(content));
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
- * 上一轮 是否有流式内容。调用后会重置。转发文件时 outbound:message 看到流完整后重复发送,需要跳过。
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
- applyStreamChunk(chunk) {
366
- if (chunk.done) {
367
- // 完结本轮流式
368
- if (this.chat.streaming) {
369
- this.streamedRecently = true;
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
- /** 终端会话生效的人设的名字:按会话设置的「人设」取卡,与回合同一口径;没有人设服务时为 undefined */
382
+ /** 终端会话生效的人设的名字:人设服务按这个会话取卡,与回合同一口径;没有人设服务或卡没取名时为 undefined */
395
383
  personaName() {
396
- const config = this.caps.sessionManager.present
397
- ? this.caps.sessionManager.use.resolveConfig(this.sessionId, 'cli')
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, `应答没有生效:${reply.message}`);
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.chat.append([`${this.formatHead(chalk.yellow('◆ System'))}审批 ${sanitizeForCard(card.tool)}:${line}`]);
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.chat.append(text.split('\n').map((line, i) => (i === 0 ? firstHead : contHead) + line));
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 || (!session.createdFrom && !session.originPlatform && !session.work);
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
- // 输入框可变高度(多行输入):内容行数 + 上下边框,最多 8 行内容。有审批卡时卡片占这个位置
722
+ // 正文下面的框:有审批卡时是卡片;chat 视图是输入框,可变高度(多行输入):内容行数 + 上下边框,最多 8 行内容;
723
+ // 别的视图是一行视图说明(框高 3 行),在正文之后算,滚动值取正文钳制过的
729
724
  const box = this.cards.length > 0
730
725
  ? this.renderCard(width, height)
731
- : this.renderInputBox(width, this.computeInputDisplayLines(width));
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.length);
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(...this.renderBody(width, bodyHeight));
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
- const persona = this.personaName() ?? 'default';
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
- for (const raw of this.chat.lines) {
814
- // 宽度感知截断为单行;超长内容直接尾部省略。
815
- lines.push(` ${clipAnsi(raw, inner)}`);
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
- return lines;
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 sec = (t) => chalk.bold.cyan(`▎ ${t}`);
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', `${this.logLines.length} 条`));
932
+ out.push(kv('log buffer', text.logs));
903
933
  out.push('');
904
- out.push(sec(`服务 (${serviceNames.length})`));
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(`平台连接 (${connections.length})`));
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 sec = (t) => chalk.bold.cyan(`▎ ${t}`);
920
- const row = (k, v) => ` ${chalk.cyan(k.padEnd(10))} ${chalk.gray(v)}`;
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
- out.push(sec('视图切换'));
923
- out.push(row('Ctrl+T', 'Chat · 聊天 / 命令输入'));
924
- out.push(row('Ctrl+L', 'Logs · 实时日志'));
925
- out.push(row('Ctrl+S', 'Status · 服务与平台状态'));
926
- out.push(row('Ctrl+G', 'Help · 当前页'));
927
- out.push(row('Esc', '返回上一个视图'));
928
- out.push(row('Ctrl+C', '退出'));
929
- out.push('');
930
- out.push(sec('滚动 (Logs / Status / Help)'));
931
- out.push(row('↑ / ↓', '逐行滚动'));
932
- out.push(row('PgUp/Dn', '翻页'));
933
- out.push(row('Home', '回到顶部'));
934
- out.push(row('End', '到底部'));
935
- out.push(row('Ctrl+W', '日志页:切换换行模式(多行完整展示)'));
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
- * 审批卡:标题行、事实、应答项,上下边框。卡片最多高到正文只剩 3 行:事实放不下时保留首尾几行,写明省略了多少行。
1000
- * 事实显示前经 sanitizeForCard 清洗,太长的保留首尾、写明省略了多少字
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 waiting = this.cards.length > 1 ? ` · 还有 ${this.cards.length - 1} 张` : '';
1006
- const title = `${chalk.bold.yellow('需要批准')} ${sanitizeForCard(card.tool)} · 会话 ${sanitizeForCard(card.sessionId)}${waiting}`;
1007
- const facts = cardFacts(card.facts).flatMap(([label, text]) => factLines(label, text, inner - 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))];
1008
1041
  // 高度:header、sep、footer 各一行,正文至少 3 行,卡片的上下边框、标题行与应答行
1009
- const room = height - 10;
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(`…(省略 ${facts.length - room + 1} 行)…`),
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, options].map(line => `${edge}${padAnsi(` ${line}`, inner)}${edge}`),
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 baseItems = [
1034
- `${chalk.cyan('^T')} chat`,
1035
- `${chalk.cyan('^L')} logs`,
1036
- `${chalk.cyan('^S')} status`,
1037
- `${chalk.cyan('^G')} help`,
1038
- `${chalk.cyan('Esc')} back`,
1039
- `${chalk.cyan('^C')} exit`,
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
- baseItems.push(`${chalk.cyan('^W')} wrap:${this.logWrap ? chalk.green('on') : chalk.gray('off')}`);
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
- const items = baseItems.join(chalk.gray(' · '));
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
- function cardFacts(facts) {
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(['命令', facts.command]);
1155
+ rows.push([labels.command, facts.command]);
1063
1156
  if (facts.cwd !== undefined)
1064
- rows.push(['目录', facts.cwd]);
1157
+ rows.push([labels.cwd, facts.cwd]);
1065
1158
  if (facts.diff)
1066
- rows.push(['文件', facts.diff.path], ['改动', facts.diff.patch ?? '疑似含密钥,不显示内容']);
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(['参数', JSON.stringify(facts.params)]);
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(visibleLen(head));
1075
- return sanitizeForCard(text, CARD_FACT_LIMIT)
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];