@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/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 { createStorageGateway, storage } from '@aalis/api-storage';
7
- import { config, definePlugin, defineService, events, LogHub, lifecycle, logger, optional, provide, services, } from '@aalis/core';
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 { ChatBuffer } from './chat-buffer.js';
13
- import { readLogFileTail } from './log-file.js';
14
- // ----- 服务描述符(按激活绑定;调用型:绑定接口是 ServiceRef)-----
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: '默认会话 ID',
31
+ label: t('sessionIdLabel'),
22
32
  default: 'cli-default',
23
33
  onInvalid: 'error',
24
- description: 'CLI 聊天所在的会话标识。',
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: '运行时自动写回;startupView=last 时启动恢复到这里记录的视图。',
40
+ description: t('lastViewDescription'),
31
41
  },
32
- prompt: { type: 'string', label: '提示符', default: 'You', description: '命令行输入提示符前缀' },
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: '日志页内存驻留的最大条数,超出后从最旧成块丢弃;完整日志始终在 data/latest.log',
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: 'CLI 接管终端后的默认视图。last 表示恢复上次视图',
53
+ description: t('startupViewDescription'),
44
54
  options: [
45
- { label: '恢复上次', value: 'last' },
46
- { label: '聊天', value: 'chat' },
47
- { label: '日志', value: 'logs' },
48
- { label: '状态', value: 'status' },
49
- { 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' },
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: 'CLI 终端',
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
- sessionTypes: [], // CLI 单会话,不区分 sessionType
128
+ // 终端界面在跑才有连接;后台运行(非 TTY)与界面起来之前没有连接,不是离线
92
129
  getConnections() {
93
- return [{ id: sessionId, platform: 'cli', status: tui?.isRunning() ? 'online' : 'offline' }];
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
- events.on('outbound:message', msg => {
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
- // 同一轮已流式输出时,agent 的整条回复是重复;系统/指令消息不是流式正文,照常显示
116
- if (msg.source === 'agent' && tui?.consumeStreamedSend())
117
- return;
118
- adapter.sendMessage(msg.sessionId, msg.content);
119
- });
120
- events.on('outbound:stream', chunk => {
121
- if (chunk.sessionId !== sessionId)
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
- tui?.applyStreamChunk(chunk);
124
- });
125
- // 临时缓冲:插件装配早于 app:started,但晚于 bootstrap-buffer.dispose();
126
- // 我们订阅一个 onEntry 把这段窗口的实时 entry 留住,并在 app:started 时合并
127
- // data/latest.log 尾部(覆盖装配之前的早期 boot 日志)。
128
- const liveBuffer = [];
129
- let liveBufferActive = true;
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
- events.on('app:started', async () => {
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
- liveBufferActive = false;
139
- stopLiveBuffer();
140
- logger.info('stdin/stdout 非 TTY,CLI 界面未启动');
210
+ stopLogs();
211
+ logger.use.info('stdin/stdout 非 TTY,CLI 界面未启动');
141
212
  return;
142
213
  }
143
- let initial = [];
144
- try {
145
- initial = await readLogFileTail(createStorageGateway(caps.storage), 2000);
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
- chat = new ChatBuffer();
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
- constructor(caps, config, sessionId, initialEntries = []) {
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
- // 启动期日志由插件入口通过尾读 data/latest.log + 实时 buffer 合并后注入,
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
- // 已注入 initialEntries 中最大的 seq;onEntry 收到 seq <= 此值的视为重复跳过
231
- let lastSeq = this.logLines.length > 0 ? this.logLines[this.logLines.length - 1].seq : -1;
232
- this.removeLogListener = LogHub.default.onEntry(entry => {
233
- if (entry.seq <= lastSeq)
234
- return;
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
- this.caps.lifecycle.onDispose(() => this.stop());
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.removeLogListener?.();
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.chat.append(this.formatAssistantBlock(content));
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
- * 上一轮 是否有流式内容。调用后会重置。转发文件时 outbound:message 看到流完整后重复发送,需要跳过。
283
- */
284
- consumeStreamedSend() {
285
- if (this.streamedRecently) {
286
- this.streamedRecently = false;
287
- return true;
288
- }
289
- return false;
290
- }
291
- applyStreamChunk(chunk) {
292
- if (chunk.done) {
293
- // 完结本轮流式
294
- if (this.chat.streaming) {
295
- this.streamedRecently = true;
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.caps.persona.current?.getPersonaName() ?? 'Aalis';
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.caps.persona.current?.getPersonaName() ?? 'Aalis';
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 = async (chunk, key) => {
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
- await this.submitLine();
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
- async submitLine() {
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.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));
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
- // userId 'console':本地终端身份(物理访问 = 运维者本人),命中 authority 的
507
- // cli:console owner 快速通道;缺省 userId 会被当成匿名回退 defaultAuthority。
508
- await this.caps.events.emit('inbound:message', {
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
- const host = this.caps.hostConfig.current;
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
- // 输入框可变高度(多行输入):内容行数 + 上下边框,最多 8 行内容
576
- const inputContentLines = this.computeInputDisplayLines(width);
577
- const inputBoxHeight = inputContentLines.length + 2;
578
- // 布局:header(1) + sep(1) + body + inputBox(inputBoxHeight) + footer(1)
579
- const bodyHeight = Math.max(3, height - 3 - inputBoxHeight);
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(...this.renderBody(width, bodyHeight));
584
- out.push(...this.renderInputBox(width, inputContentLines));
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
- const persona = this.caps.persona.current?.getPersonaName() ?? 'default';
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
- for (const raw of this.chat.lines) {
660
- // 宽度感知截断为单行;超长内容直接尾部省略。
661
- 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)))];
662
827
  }
663
- 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))}`;
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(全量) 重建。完整日志始终在 data/latest.log。 */
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.caps.persona.current?.getPersonaName() ?? '-';
742
- const sec = (t) => chalk.bold.cyan(`▎ ${t}`);
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', `${this.logLines.length} 条`));
932
+ out.push(kv('log buffer', text.logs));
749
933
  out.push('');
750
- out.push(sec(`服务 (${serviceNames.length})`));
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(`平台连接 (${connections.length})`));
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 sec = (t) => chalk.bold.cyan(`▎ ${t}`);
766
- 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);
767
957
  const out = [];
768
- out.push(sec('视图切换'));
769
- out.push(row('Ctrl+T', 'Chat · 聊天 / 命令输入'));
770
- out.push(row('Ctrl+L', 'Logs · 实时日志'));
771
- out.push(row('Ctrl+S', 'Status · 服务与平台状态'));
772
- out.push(row('Ctrl+G', 'Help · 当前页'));
773
- out.push(row('Esc', '返回上一个视图'));
774
- out.push(row('Ctrl+C', '退出'));
775
- out.push('');
776
- out.push(sec('滚动 (Logs / Status / Help)'));
777
- out.push(row('↑ / ↓', '逐行滚动'));
778
- out.push(row('PgUp/Dn', '翻页'));
779
- out.push(row('Home', '回到顶部'));
780
- out.push(row('End', '到底部'));
781
- out.push(row('Ctrl+W', '日志页:切换换行模式(多行完整展示)'));
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 baseItems = [
847
- `${chalk.cyan('^T')} chat`,
848
- `${chalk.cyan('^L')} logs`,
849
- `${chalk.cyan('^S')} status`,
850
- `${chalk.cyan('^G')} help`,
851
- `${chalk.cyan('Esc')} back`,
852
- `${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')],
853
1072
  ];
1073
+ const items = keys.map(([key, text]) => `${chalk.cyan(key)} ${this.localize(text)}`);
854
1074
  if (this.view === 'logs') {
855
- 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 }))}`);
856
1077
  }
857
- const items = baseItems.join(chalk.gray(' · '));
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];