foliko 1.1.63 → 1.1.64

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/cli/bin/foliko.js CHANGED
@@ -3,8 +3,8 @@
3
3
  * Foliko CLI 入口
4
4
  * Usage: foliko <command> [options]
5
5
  */
6
- // 加载 dotenv
7
- require('dotenv').config({ quiet: true });
6
+ // 加载 .env 文件(覆盖系统环境变量,确保 .env 优先级最高)
7
+ require('dotenv').config({ override: true });
8
8
 
9
9
  const { cli } = require('../src');
10
10
 
@@ -7,50 +7,39 @@ const path = require('path');
7
7
  const { Framework } = require('../../../src');
8
8
  const { ChatUI } = require('../ui/chat-ui');
9
9
  const { logger, LOG_LEVELS } = require('../../../src/utils/logger');
10
+ const { DEFAULT_PROVIDERS } = require('../../../src/core/provider');
10
11
 
11
12
  // // 加载 .env 文件
12
13
  // dotenv.config({ override: true,quiet: true});
13
14
 
14
- // 默认配置
15
- const DEFAULT_CONFIG = {
16
- model: 'MiniMax-M2.7',
17
- provider: 'minimax',
18
- baseURL: 'https://api.minimaxi.com/v1',
19
- apiKey: null,
20
- };
21
-
22
- // Provider 默认配置
23
- const PROVIDER_DEFAULTS = {
24
- minimax: {
25
- model: 'MiniMax-M2.7',
26
- baseURL: 'https://api.minimaxi.com/v1',
27
- },
28
- deepseek: {
29
- model: 'deepseek-chat',
30
- baseURL: 'https://api.deepseek.com/v1',
31
- },
15
+ // 各 provider 默认使用的模型
16
+ const PROVIDER_MODELS = {
17
+ minimax: 'MiniMax-M2.7',
18
+ deepseek: 'deepseek-chat',
19
+ openai: 'gpt-4o',
20
+ anthropic: 'claude-sonnet-4-20250514',
32
21
  };
33
22
 
34
23
  /**
35
24
  * 获取环境变量配置
36
25
  */
37
26
  function getEnvConfig() {
38
- const provider = process.env.FOLIKO_PROVIDER || DEFAULT_CONFIG.provider;
39
- const providerDefaults = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.minimax;
27
+ const provider = process.env.FOLIKO_PROVIDER || 'minimax';
28
+ const providerInfo = DEFAULT_PROVIDERS[provider];
29
+ const defaultModel = PROVIDER_MODELS[provider] || 'deepseek-chat';
40
30
 
41
- // 支持多种 API key 环境变量名
31
+ // API key: 优先 FOLIKO_API_KEY,否则找 {PROVIDER}_API_KEY
42
32
  let apiKey = process.env.FOLIKO_API_KEY || null;
43
33
  if (!apiKey) {
44
- // 根据 provider 查找对应的 API key
45
34
  const upperProvider = provider.toUpperCase().replace(/-/g, '_');
46
35
  apiKey = process.env[`${upperProvider}_API_KEY`] || null;
47
36
  }
48
37
 
49
38
  return {
50
- model: process.env.FOLIKO_MODEL || providerDefaults.model,
51
- provider: provider,
52
- baseURL: process.env.FOLIKO_BASE_URL || providerDefaults.baseURL,
53
- apiKey: apiKey,
39
+ model: process.env.FOLIKO_MODEL || defaultModel,
40
+ provider,
41
+ baseURL: process.env.FOLIKO_BASE_URL || (providerInfo ? providerInfo.baseURL : null),
42
+ apiKey,
54
43
  };
55
44
  }
56
45
 
@@ -5,7 +5,7 @@
5
5
  const chalk = require('chalk').default;
6
6
  const figlet = require('figlet');
7
7
  const { CLEAR_LINE, CYAN, DIM, GREEN, RED, YELLOW, colored } = require('../utils/ansi');
8
- const { TUI, ProcessTerminal, Editor, Markdown, Loader, Text, Spacer, CombinedAutocompleteProvider, matchesKey, Container, TruncatedText, visibleWidth } = require('@earendil-works/pi-tui');
8
+ const { TUI, ProcessTerminal, Editor, Text, CombinedAutocompleteProvider, matchesKey, Container } = require('@earendil-works/pi-tui');
9
9
  const { cleanResponse } = require('../../../src/utils');
10
10
  const { logEmitter } = require('../../../src/utils/logger');
11
11
  const { renderLine } = require('../utils/markdown');
@@ -14,6 +14,7 @@ const Queue=require('js-queue');
14
14
  const hl = require('cli-highlight');
15
15
  const { renderDiffWithHeader } = require('../utils/render-diff');
16
16
  const { FooterBar } = require('./footer-bar');
17
+ const { StatusBar } = require('./status-bar');
17
18
  const queue=new Queue();
18
19
  // Foliko 主色(蓝绿)
19
20
  const folikoPrimary = chalk.hex('#2A9D8F');
@@ -70,15 +71,15 @@ class ChatUI {
70
71
  const terminal = new ProcessTerminal();
71
72
  const tui=this.tui = new TUI(terminal);
72
73
  this.messageContainer= new Container(0)
73
- this.statusContainer= new Container(0)
74
74
  const text=`${folikoTerracotta("Ctrl+C")} 退出 | ${folikoTerracotta("/")} 指令列表 | ${folikoTerracotta("Enter")} 发送 | ${folikoTerracotta("Shift+Enter")} 换行`
75
75
  this.messageContainer.addChild(
76
76
  new Text(FOLIKO_LOGO + folikoGold(`\n欢迎使用Foliko!\n`)+folikoSand(text),3,2),
77
77
  );
78
78
  this.tui.addChild(this.messageContainer)
79
- this.tui.addChild(this.statusContainer)
80
-
81
79
 
80
+ // 状态栏组件(通知/工具调用/思考中),插在消息区和编辑器之间
81
+ this.statusBar = new StatusBar(tui, this.editor);
82
+ this.tui.addChild(this.statusBar.container);
82
83
 
83
84
  this._currentBotMessage=null
84
85
 
@@ -135,132 +136,6 @@ class ChatUI {
135
136
  if (initialFooter.length > 0) {
136
137
  this._footerText.setText(initialFooter[0]);
137
138
  }
138
- const tooler=new Loader(
139
- tui,
140
- (s) => chalk.green(s), // indicatorColorFn
141
- (s) => chalk.yellow(s), // messageColorFn
142
- "工具调用...",
143
- {
144
- frames:["|", "/", "-", "\\"].map(str=>folikoTerracotta(str))
145
- }
146
- )
147
- const loader=new Loader(
148
- tui,
149
- (s) => chalk.cyan(s), // indicatorColorFn
150
- (s) => chalk.dim(s), // messageColorFn
151
- "正在思考中...",
152
- {
153
- frames:["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"].map(str=>folikoPrimary(str))
154
- }
155
- )
156
- // Spacer 占位,用于隐藏时保持布局
157
- const toolerSpacer = new Spacer(0);
158
- const loaderSpacer = new Spacer(0);
159
- const notifierSpacer = new Spacer(0);
160
-
161
- this.statusContainer.addChild(toolerSpacer); // tooler 位置
162
- this.statusContainer.addChild(loaderSpacer); // loader 位置
163
- this.statusContainer.addChild(notifierSpacer); // notifier 位置
164
-
165
- const self = this;
166
-
167
- this.tooler = {
168
- dom: tooler,
169
- spacer: toolerSpacer,
170
- showed:false,
171
- index:1,
172
- show(text) {
173
- this.showed = true;
174
- self.statusContainer.children[this.index] = self.tooler.dom;
175
- this.dom.setMessage(text);
176
- tui.requestRender();
177
- return self.tooler.dom;
178
- },
179
- hide() {
180
- this.showed = false;
181
- self.statusContainer.children[this.index] = self.tooler.spacer;
182
- tui.requestRender();
183
- },
184
- setText(text) {
185
- if(!this.showed){
186
- this.show(text);
187
- } else {
188
- this.dom.setMessage(text);
189
- }
190
- }
191
- };
192
-
193
- this.notifier = {
194
- dom: null,
195
- spacer: notifierSpacer,
196
- showed: false,
197
- _timeout: null,
198
- index:0,
199
- show(text, duration = 5000) {
200
- if (self.notifier._timeout) {
201
- clearTimeout(self.notifier._timeout);
202
- }
203
- if (!self.notifier.dom) {
204
- self.notifier.dom = new Loader(
205
- tui,
206
- (s) => chalk.cyan(s),
207
- (s) => chalk.yellow(s),
208
- text,
209
- );
210
- }
211
- self.notifier.dom.setMessage(text);
212
- self.statusContainer.children[this.index] = self.notifier.dom;
213
- self.notifier.showed = true;
214
- tui.requestRender();
215
- self.notifier._timeout = setTimeout(() => {
216
- self.notifier.hide();
217
- }, duration);
218
- },
219
- hide() {
220
- if (self.notifier._timeout) {
221
- clearTimeout(self.notifier._timeout);
222
- self.notifier._timeout = null;
223
- }
224
- self.notifier.showed = false;
225
- self.statusContainer.children[this.index] = self.notifier.spacer;
226
- tui.requestRender();
227
- },
228
- };
229
-
230
- this.loader = {
231
- dom: loader,
232
- spacer: loaderSpacer,
233
- startTime: null,
234
- timer: null,
235
- index:2,
236
- show() {
237
- self.statusContainer.children[this.index] = self.loader.dom;
238
- editor.disableSubmit = true;
239
- self.startTime = Date.now();
240
- self.loader.dom.setMessage("正在思考中... (0秒)");
241
- self.loader.dom.start();
242
- // 每秒更新计时
243
- self.loader.timer = setInterval(() => {
244
- const elapsed = Math.floor((Date.now() - self.startTime) / 1000);
245
- const seconds = elapsed % 60;
246
- const minutes = Math.floor(elapsed / 60);
247
- const timeStr = minutes > 0 ? `${minutes}分${seconds}秒` : `${seconds}秒`;
248
- self.loader.dom.setMessage(`正在思考中... (${timeStr})`);
249
- }, 1000);
250
- tui.requestRender();
251
- return self.loader.dom;
252
- },
253
- hide() {
254
- editor.disableSubmit = false;
255
- if (self.loader.timer) {
256
- clearInterval(self.loader.timer);
257
- self.loader.timer = null;
258
- }
259
- self.loader.dom.stop();
260
- self.statusContainer.children[this.index] = self.loader.spacer;
261
- tui.requestRender();
262
- }
263
- };
264
139
  tui.requestRender();
265
140
  // 中断状态(供监听器访问)
266
141
  this.interrupted = false;
@@ -270,6 +145,11 @@ class ChatUI {
270
145
 
271
146
  // 流式输出缓冲区(可重置)
272
147
  this._lineBuffer = '';
148
+ // 流式渲染缓冲区:合并高频 chunk 后统一刷新 Markdown,减少重复解析
149
+ this._streamBufferTimer = null;
150
+ this._pendingTextUpdate = false;
151
+ // renderLine 的共享状态(引用传递,跨调用保持 codeBlock/think 状态)
152
+ this._renderState = { inThink: true, inCodeBlock: false };
273
153
  // agent.framework.on("console:log",async (...args)=>{
274
154
  // this.create_message(args.join(' '),chalk.dim('● '),true);
275
155
  // })
@@ -318,11 +198,25 @@ class ChatUI {
318
198
  }
319
199
  const time = timestamp ? new Date(timestamp).toLocaleTimeString('zh-CN') : '';
320
200
  const notificationText = `🔔 [${source}] ${title}${time ? ` (${time})` : ''}`;
321
- this.notifier.show(notificationText);
201
+ this.statusBar.notifier.show(notificationText);
322
202
  // 显示消息内容
323
203
  this.create_message(`**${title}**\n${message}`, "🔔 ");
324
204
  };
325
205
  this.agent.framework.on('notification', this._notificationHandler);
206
+
207
+ // 监听 usage 事件(用于 footer 更新)
208
+ this._usageHandler = (data) => {
209
+ if (this.footerBar && data.usage) {
210
+ const u = data.usage;
211
+ this.footerBar.updateUsage(u.promptTokens || 0, u.completionTokens || 0);
212
+ const footerLines = this.footerBar.render(this.tui.width || process.stdout.columns || 80);
213
+ if (footerLines.length > 0 && this._footerText) {
214
+ this._footerText.setText(footerLines[0]);
215
+ }
216
+ this.tui.requestRender();
217
+ }
218
+ };
219
+ agent.framework.on('agent:usage', this._usageHandler);
326
220
  }
327
221
  }
328
222
 
@@ -330,6 +224,11 @@ class ChatUI {
330
224
  * 清理监听器
331
225
  */
332
226
  dispose() {
227
+ // 清理流式缓冲区定时器
228
+ if (this._streamBufferTimer) {
229
+ clearTimeout(this._streamBufferTimer);
230
+ this._streamBufferTimer = null;
231
+ }
333
232
  if (this._logHandler && this.agent.framework) {
334
233
  logEmitter.off('log', this._logHandler);
335
234
  this._logHandler = null;
@@ -338,11 +237,21 @@ class ChatUI {
338
237
  this.agent.framework.off('notification', this._notificationHandler);
339
238
  this._notificationHandler = null;
340
239
  }
240
+ if (this._usageHandler && this.agent.framework) {
241
+ this.agent.framework.off('agent:usage', this._usageHandler);
242
+ this._usageHandler = null;
243
+ }
341
244
  }
342
245
 
343
246
  clear_message_done(){
344
- this.loader.hide();
345
- this.tooler.hide();
247
+ this._flushStreamBuffer(); // 确保最后的文本被刷新
248
+ if (this._streamBufferTimer) {
249
+ clearTimeout(this._streamBufferTimer);
250
+ this._streamBufferTimer = null;
251
+ }
252
+ this._pendingTextUpdate = false;
253
+ this.statusBar.loader.hide();
254
+ this.statusBar.tooler.hide();
346
255
  this._lineBuffer=""
347
256
  this.isResponding=false
348
257
  this._currentBotMessage=null
@@ -409,11 +318,8 @@ class ChatUI {
409
318
  }
410
319
 
411
320
  create_message(text, icon="", isBot=false, bgFn=null){
412
- // 使用 MessageBubble 组件实现左侧图标 + 右侧 markdown 布局
413
- // bgFn: 可选背景色函数 (line) => coloredLine,用于代码块等整块背景
414
321
  const bubble = new MessageBubble(icon, text, isBot, markdownTheme, 0, 0, 1, bgFn);
415
- const children = this.messageContainer.children;
416
- children.splice(children.length, 0, bubble);
322
+ this.messageContainer.addChild(bubble);
417
323
  this.tui.requestRender();
418
324
  return bubble
419
325
  }
@@ -422,8 +328,8 @@ class ChatUI {
422
328
 
423
329
  _clearContext() {
424
330
  // 隐藏 loader 和 tooler
425
- this.tooler.hide();
426
- this.loader.hide();
331
+ this.statusBar.tooler.hide();
332
+ this.statusBar.loader.hide();
427
333
 
428
334
  const { sessionId } = this;
429
335
 
@@ -457,9 +363,10 @@ class ChatUI {
457
363
  }
458
364
 
459
365
  // 清空 messageContainer 中的消息,保留 welcome
460
- const msgChildren = this.messageContainer.children;
461
- while (msgChildren.length > 1) {
462
- msgChildren.splice(1, 1);
366
+ const welcomeChild = this.messageContainer.children[0];
367
+ this.messageContainer.clear();
368
+ if (welcomeChild) {
369
+ this.messageContainer.addChild(welcomeChild);
463
370
  }
464
371
 
465
372
  // 重置 footer 统计
@@ -475,8 +382,8 @@ class ChatUI {
475
382
 
476
383
  _compressContext() {
477
384
  // 隐藏 loader 和 tooler
478
- this.tooler.hide();
479
- this.loader.hide();
385
+ this.statusBar.tooler.hide();
386
+ this.statusBar.loader.hide();
480
387
 
481
388
  const { sessionId } = this;
482
389
 
@@ -504,14 +411,14 @@ class ChatUI {
504
411
  }
505
412
 
506
413
  const beforeCount = messages ? messages.length : 0;
507
- this.tooler.show(`${colored('[压缩]', YELLOW)} 压缩前: ${beforeCount} 条`);
414
+ this.statusBar.tooler.show(`${colored('[压缩]', YELLOW)} 压缩前: ${beforeCount} 条`);
508
415
 
509
416
  if (messages && messages.length > 0) {
510
417
  // 使用 ContextCompressor 压缩
511
418
  if (chatHandler._contextCompressor) {
512
419
  chatHandler._contextCompressor.compress(sessionId, messages, messageStore).then(() => {
513
420
  const afterCount = messages.length;
514
- this.tooler.setText(`${colored('[压缩]', YELLOW)} 压缩后: ${afterCount} 条 (保留${chatHandler._contextCompressor._keepRecentMessages}条)`);
421
+ this.statusBar.tooler.setText(`${colored('[压缩]', YELLOW)} 压缩后: ${afterCount} 条 (保留${chatHandler._contextCompressor._keepRecentMessages}条)`);
515
422
  // 同步回 SessionContext
516
423
  if (this.agent.framework) {
517
424
  const sessionCtx = this.agent.framework.getSessionContext(sessionId);
@@ -520,49 +427,79 @@ class ChatUI {
520
427
  }
521
428
  }
522
429
  setTimeout(() => {
523
- this.tooler.hide();
430
+ this.statusBar.tooler.hide();
524
431
  this.create_message(`${colored('[提示]', CYAN)} 上下文已压缩`,chalk.dim('● '))
525
432
  this.tui.requestRender();
526
433
  }, 2000);
527
434
  }).catch(err => {
528
- this.tooler.setText(`${colored('[错误]', RED)} 压缩失败: ${err.message}`);
435
+ this.statusBar.tooler.setText(`${colored('[错误]', RED)} 压缩失败: ${err.message}`);
529
436
  });
530
437
  } else {
531
- this.tooler.setText(`${colored('[错误]', RED)} 无 ContextCompressor`);
438
+ this.statusBar.tooler.setText(`${colored('[错误]', RED)} 无 ContextCompressor`);
532
439
  }
533
440
  } else {
534
- this.tooler.setText(`${colored('[提示]', CYAN)} 无消息可压缩`);
441
+ this.statusBar.tooler.setText(`${colored('[提示]', CYAN)} 无消息可压缩`);
535
442
  }
536
443
  } else {
537
- this.tooler.setText(`${colored('[错误]', RED)} 无 AgentChatHandler`);
444
+ this.statusBar.tooler.setText(`${colored('[错误]', RED)} 无 AgentChatHandler`);
538
445
  }
539
446
  }
447
+ /**
448
+ * 刷新流式输出缓冲区:合并高频 chunk 后统一渲染,减少 Markdown 重复解析
449
+ */
450
+ _flushStreamBuffer() {
451
+ this._streamBufferTimer = null;
452
+ if (!this._lineBuffer) return;
453
+
454
+ // 渲染剩余的 partial line(没有 \n 结尾的尾部文字)
455
+ const rendered = renderLine(this._lineBuffer, this._renderState, true);
456
+ if (!this._currentBotMessage) {
457
+ this._currentBotMessage = this.create_message(rendered, colored('● ', GREEN), true);
458
+ } else {
459
+ this._currentBotMessage.appendContent(rendered);
460
+ }
461
+ this._lineBuffer = '';
462
+ }
463
+
540
464
  /**
541
465
  * 设置 session scope 事件监听
542
466
  */
543
467
  _setupSessionListeners() {
544
- const renderState = { inThink: true, inCodeBlock: false };
545
-
546
468
  this.sessionScope.on('stream:chunk', ({ chunk }) => {
547
469
  if (this.interrupted) return;
548
470
 
549
471
  if (chunk.type === 'text') {
550
- this._lineBuffer += chunk.text;
551
-
552
- if(!this._currentBotMessage){
553
- // AI 回复,让 create_message 添加前缀
554
- this._currentBotMessage = this.create_message(renderLine(this._lineBuffer,renderState,true), colored('', GREEN),true);
555
- }else{
556
- // 已经有前缀,直接更新内容
557
- this._currentBotMessage.setText(renderLine(this._lineBuffer,renderState,true));
472
+ this._lineBuffer += chunk.text
473
+
474
+ // 逐行处理:遇到 \n 立即渲染完整行,实现即时反馈
475
+ // 行尾补回 \n 确保 TUI 正确换行(空行也要渲染,保留段落分隔)
476
+ // while (this._lineBuffer.includes('\n')) {
477
+ // if (this.interrupted) break;
478
+
479
+ // const nlIndex = this._lineBuffer.indexOf('\n');
480
+ // const line = this._lineBuffer.substring(0, nlIndex);
481
+ // this._lineBuffer = this._lineBuffer.substring(nlIndex + 1);
482
+
483
+ // const rendered = line;
484
+ // if (!this._currentBotMessage) {
485
+ // this._currentBotMessage = this.create_message(rendered, colored('● ', GREEN), true);
486
+ // } else {
487
+ // this._currentBotMessage.appendContent(rendered);
488
+ // }
489
+
490
+ // }
491
+
492
+ // 兜底:partial line(无 \n 结尾)用 timer 刷新,防止尾部文字卡住
493
+ if (this._lineBuffer && !this._streamBufferTimer) {
494
+ this._streamBufferTimer = setTimeout(() => this._flushStreamBuffer(), 0);
558
495
  }
559
496
  } else if (chunk.type === 'tool-call') {
560
497
  const args = chunk.input ? JSON.stringify(chunk.input).slice(0, 50) : '';
561
- this.tooler.show(`${chalk.yellow('[Tool]')} ${folikoGold(chunk.toolName)} ${chalk.gray(args+'...')}`)
498
+ this.statusBar.tooler.show(`${chalk.yellow('[Tool]')} ${folikoGold(chunk.toolName)} ${chalk.gray(args+'...')}`)
562
499
  } else if(chunk.type==='tool-result'){
563
500
  const result = chunk.result;
564
501
  const shortResult = typeof result === 'string' ? result.slice(0, 30) : JSON.stringify(result).slice(0, 30);
565
- this.tooler.show(`${chalk.green('[Tool]')} ${folikoGold(chunk.toolName)} ${chalk.gray(shortResult+'...')}`)
502
+ this.statusBar.tooler.show(`${chalk.green('[Tool]')} ${folikoGold(chunk.toolName)} ${chalk.gray(shortResult+'...')}`)
566
503
 
567
504
  // 如果是 edit/edit_file 的结果并且包含 diff,渲染彩色 diff
568
505
  if (chunk.toolName === 'edit' || chunk.toolName === 'edit_file') {
@@ -575,7 +512,7 @@ class ChatUI {
575
512
  const diffContent = renderDiffWithHeader(resultObj.diff, resultObj.filePath);
576
513
  const bgColor = chalk.bgHex('#1a1a2e');
577
514
  this.create_message(diffContent, chalk.yellow('▸ '), true, (line) => bgColor(line));
578
- this.tooler.show(`${chalk.green('[Diff]')} ${folikoGold(resultObj.filePath)}`);
515
+ this.statusBar.tooler.show(`${chalk.green('[Diff]')} ${folikoGold(resultObj.filePath)}`);
579
516
  }
580
517
  }
581
518
  // setTimeout(() => this.tooler.hide(), 3000);
@@ -592,7 +529,7 @@ class ChatUI {
592
529
  }
593
530
  });
594
531
  this.sessionScope.on('message:start', async () => {
595
- this.loader.show();
532
+ this.statusBar.loader.show();
596
533
  })
597
534
  this.sessionScope.on('message:complete', async ({ content, requestId }) => {
598
535
  try{
@@ -6,12 +6,13 @@
6
6
  */
7
7
 
8
8
  const chalk = require('chalk').default;
9
+ const { visibleWidth, truncateToWidth } = require('@earendil-works/pi-tui');
9
10
  const { TokenCounter } = require('../../../src/core/token-counter');
10
11
 
11
12
  // Foliko 配色
12
13
  const folikoDim = chalk.hex('#6B7280');
13
14
  const folikoAccent = chalk.hex('#2A9D8F');
14
- const folikoGold = chalk.hex('#E9C46A');
15
+ const folikoGold = chalk.hex('#999a9c');
15
16
 
16
17
  class FooterBar {
17
18
  /**
@@ -182,10 +183,10 @@ class FooterBar {
182
183
  const parts = [];
183
184
 
184
185
  // ↑ input tokens
185
- parts.push(folikoGold(`↑${this._formatNum(s.inputTokens)}`));
186
+ parts.push(chalk.dim(`↑${this._formatNum(s.inputTokens)}`));
186
187
 
187
188
  // ↓ output tokens
188
- parts.push(folikoGold(`↓${this._formatNum(s.outputTokens)}`));
189
+ parts.push(chalk.dim(`↓${this._formatNum(s.outputTokens)}`));
189
190
 
190
191
  // $ cost
191
192
  if (s.cost > 0) {
@@ -212,7 +213,7 @@ class FooterBar {
212
213
  let line = parts.join(' ');
213
214
 
214
215
  // 计算实际显示宽度(去除 ANSI 编码)
215
- const visibleLen = visibleWidth ? visibleWidth(line) : line.length;
216
+ const visibleLen = visibleWidth(line);
216
217
 
217
218
  // 如果太长,缩短模型名
218
219
  if (visibleLen > width) {
@@ -226,38 +227,12 @@ class FooterBar {
226
227
  }
227
228
 
228
229
  // 截断到可用宽度
229
- if (visibleWidth && visibleWidth(line) > width) {
230
- line = truncateToWidth ? truncateToWidth(line, width) : line.slice(0, width);
230
+ if (visibleWidth(line) > width) {
231
+ line = truncateToWidth(line, width);
231
232
  }
232
233
 
233
234
  return [line];
234
235
  }
235
236
  }
236
237
 
237
- function visibleWidth(text) {
238
- // 简易 ANSI 不可见宽度计算
239
- const stripped = text.replace(/\x1B\[[0-9;]*m/g, '');
240
- return stripped.length;
241
- }
242
-
243
- function truncateToWidth(text, maxWidth) {
244
- const stripped = text.replace(/\x1B\[[0-9;]*m/g, '');
245
- if (stripped.length <= maxWidth) return text;
246
-
247
- // 需要截断,保留 ANSI 编码不变
248
- let result = '';
249
- let visLen = 0;
250
- const regex = /(\x1B\[[0-9;]*m)|(.)/g;
251
- let match;
252
- while ((match = regex.exec(text)) !== null && visLen < maxWidth) {
253
- if (match[1]) {
254
- result += match[1];
255
- } else {
256
- result += match[2];
257
- visLen++;
258
- }
259
- }
260
- return result;
261
- }
262
-
263
238
  module.exports = { FooterBar };
@@ -28,11 +28,22 @@ class MessageBubble {
28
28
  this._cachedLines = null;
29
29
  }
30
30
 
31
+ /**
32
+ * 实现 pi-tui Component 的 invalidate 接口
33
+ * 主题变化时由 TUI 调用,清除所有缓存
34
+ */
35
+ invalidate() {
36
+ this._cacheKey = null;
37
+ this._cachedLines = null;
38
+ if (this._markdown) {
39
+ this._markdown.invalidate?.();
40
+ }
41
+ }
42
+
31
43
  setContent(content) {
32
44
  if (this.content !== content) {
33
45
  this.content = content;
34
- this._cacheKey = null;
35
- this._cachedLines = null;
46
+ this.invalidate();
36
47
  }
37
48
  }
38
49
 
@@ -41,6 +52,17 @@ class MessageBubble {
41
52
  this.setContent(content);
42
53
  }
43
54
 
55
+ /**
56
+ * 追加已渲染的内容(流式输出增量追加)
57
+ * 比 setText 更高效:跳过全量置换,只追加新内容并清缓存
58
+ * @param {string} renderedDelta - 新渲染的 ANSI 文本片段
59
+ */
60
+ appendContent(renderedDelta) {
61
+ this.content += renderedDelta;
62
+ this._cacheKey = null;
63
+ this._cachedLines = null;
64
+ }
65
+
44
66
  render(width) {
45
67
  // 检查缓存
46
68
  const cacheKey = `${width}:${this.content}`;