@raolin2025/claude-code-node 2.6.18 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "2.6.18",
3
+ "version": "2.7.1",
4
4
  "description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming, rich media upload, multi-account management",
5
5
  "type": "module",
6
6
  "main": "src/core/index.js",
@@ -0,0 +1,243 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import { PassThrough, Writable } from 'node:stream'
4
+ import { createMultilineInput } from '../core/multiline-input.js'
5
+
6
+ // 创建一个模拟 TTY 的输入流和一个收集 stdout 的输出流
7
+ function makeEnv() {
8
+ const input = new PassThrough()
9
+ input.isTTY = true
10
+ input.setRawMode = () => {}
11
+ const output = new Writable({ write(c, _e, cb) { this.buf += c.toString(); cb() } })
12
+ output.buf = ''
13
+ return { input, output }
14
+ }
15
+
16
+ // 让事件循环跑一下,等待异步提交
17
+ const tick = () => new Promise((r) => setTimeout(r, 30))
18
+
19
+ test('多行输入被完整提交(含内嵌换行,不被断句)', async () => {
20
+ const { input, output } = makeEnv()
21
+ const submitted = []
22
+ const ctrl = createMultilineInput({
23
+ stdin: input, stdout: output, prompt: '> ',
24
+ onSubmit: (t) => submitted.push(t),
25
+ })
26
+ ctrl.start()
27
+
28
+ input.write('第一行')
29
+ input.write('\n') // 文本换行 → 折行,不提交
30
+ input.write('第二行')
31
+ input.write('\r') // Enter → 提交
32
+
33
+ await tick()
34
+ assert.deepStrictEqual(submitted, ['第一行\n第二行'])
35
+ })
36
+
37
+ test('CRLF 提交后残留 \\n 被忽略,不产生脏输入', async () => {
38
+ const { input, output } = makeEnv()
39
+ const submitted = []
40
+ const ctrl = createMultilineInput({
41
+ stdin: input, stdout: output, prompt: '> ',
42
+ onSubmit: (t) => submitted.push(t),
43
+ })
44
+ ctrl.start()
45
+
46
+ input.write('A段')
47
+ input.write('\r\n') // CRLF
48
+ input.write('B段')
49
+ input.write('\r')
50
+
51
+ await tick()
52
+ assert.deepStrictEqual(submitted, ['A段', 'B段'])
53
+ })
54
+
55
+ test('连续多条输入互不干扰,各自完整', async () => {
56
+ const { input, output } = makeEnv()
57
+ const submitted = []
58
+ const ctrl = createMultilineInput({
59
+ stdin: input, stdout: output, prompt: '> ',
60
+ onSubmit: (t) => submitted.push(t),
61
+ })
62
+ ctrl.start()
63
+
64
+ input.write('文章一\n带换行')
65
+ input.write('\r')
66
+ input.write('文章二')
67
+ input.write('\r')
68
+
69
+ await tick()
70
+ assert.deepStrictEqual(submitted, ['文章一\n带换行', '文章二'])
71
+ })
72
+
73
+ test('上方向键调出输入历史', async () => {
74
+ const { input, output } = makeEnv()
75
+ const submitted = []
76
+ const ctrl = createMultilineInput({
77
+ stdin: input, stdout: output, prompt: '> ',
78
+ onSubmit: (t) => submitted.push(t),
79
+ })
80
+ ctrl.start()
81
+
82
+ input.write('历史输入')
83
+ input.write('\r')
84
+ input.write('\x1b[A') // 上方向键
85
+ input.write('\r')
86
+
87
+ await tick()
88
+ assert.deepStrictEqual(submitted, ['历史输入', '历史输入'])
89
+ })
90
+
91
+ test('ask() 单行问题返回用户回答', async () => {
92
+ const { input, output } = makeEnv()
93
+ const ctrl = createMultilineInput({
94
+ stdin: input, stdout: output, prompt: '> ',
95
+ })
96
+ ctrl.start()
97
+
98
+ const p = ctrl.ask('是否允许? (y/N)')
99
+ input.write('y')
100
+ input.write('\r')
101
+
102
+ const ans = await p
103
+ assert.strictEqual(ans, 'y')
104
+ })
105
+
106
+ test('ask() 在提问期间不污染主输入缓冲', async () => {
107
+ const { input, output } = makeEnv()
108
+ const submitted = []
109
+ const ctrl = createMultilineInput({
110
+ stdin: input, stdout: output, prompt: '> ',
111
+ onSubmit: (t) => submitted.push(t),
112
+ })
113
+ ctrl.start()
114
+
115
+ const p = ctrl.ask('选一个? ')
116
+ input.write('x')
117
+ input.write('\r')
118
+ await p
119
+
120
+ // 提问结束后,主输入缓冲应为空,输入新内容提交正常
121
+ input.write('新输入')
122
+ input.write('\r')
123
+ await tick()
124
+ assert.deepStrictEqual(submitted, ['新输入'])
125
+ })
126
+
127
+ test('单行输入回显稳定:不发出光标上移\\x1b[1A(防止乱跳)', async () => {
128
+ const { input, output } = makeEnv()
129
+ output.columns = 80
130
+ const ctrl = createMultilineInput({
131
+ stdin: input, stdout: output, prompt: '> ',
132
+ onSubmit: () => {},
133
+ })
134
+ ctrl.start()
135
+
136
+ input.write('hello')
137
+ await tick()
138
+
139
+ const raw = output.buf
140
+ // 单行输入时绝不出现光标上移序列(会导致屏幕乱跳)
141
+ assert.ok(!raw.includes('\x1b[1A'), `单行输入不应上移光标,实际输出: ${JSON.stringify(raw)}`)
142
+ // 应通过 \r + 清屏 + 重绘实现回显
143
+ assert.ok(raw.includes('\x1b[J'), '应有清屏序列')
144
+ // 最终内容应完整显示
145
+ assert.ok(raw.includes('> hello'), '输入内容应完整回显')
146
+ })
147
+
148
+ test('多行输入回显稳定:重绘只上移到首行一次,无多余跳转', async () => {
149
+ const { input, output } = makeEnv()
150
+ output.columns = 80
151
+ const ctrl = createMultilineInput({
152
+ stdin: input, stdout: output, prompt: '> ',
153
+ onSubmit: () => {},
154
+ })
155
+ ctrl.start()
156
+
157
+ input.write('第一行')
158
+ input.write('\n') // 折行
159
+ input.write('第二行')
160
+ await tick()
161
+
162
+ const raw = output.buf
163
+ // 折行后输入第二行时,应只发出一次 \x1b[1A(上移到首行)+ 清屏 + 重绘
164
+ // 不应出现 \x1b[1B(下移)或 \x1b[nC 这类多余跳转组合
165
+ assert.ok(!raw.includes('\x1b[1B'), '不应有光标下移跳转')
166
+ // 内容应包含两行完整回显
167
+ assert.ok(raw.includes('> 第一行'), '第一行应回显')
168
+ assert.ok(raw.includes('第二行'), '第二行应回显')
169
+ })
170
+
171
+ test('Alt+Enter(Esc+Enter)折行、普通 Enter 提交 — 跨终端可靠多行输入', async () => {
172
+ const { input, output } = makeEnv()
173
+ const submitted = []
174
+ const ctrl = createMultilineInput({
175
+ stdin: input, stdout: output, prompt: '> ',
176
+ onSubmit: (t) => submitted.push(t),
177
+ })
178
+ ctrl.start()
179
+
180
+ // 第一行 + Alt+Enter(\x1b\r → meta+return)折行
181
+ input.write('第一行')
182
+ input.write('\x1b\r')
183
+ // 第二行 + 普通 Enter 提交
184
+ input.write('第二行')
185
+ input.write('\r')
186
+
187
+ await tick()
188
+ assert.deepStrictEqual(submitted, ['第一行\n第二行'])
189
+ })
190
+
191
+ test('普通 Enter 仍是提交(单行输入不受影响)', async () => {
192
+ const { input, output } = makeEnv()
193
+ const submitted = []
194
+ const ctrl = createMultilineInput({
195
+ stdin: input, stdout: output, prompt: '> ',
196
+ onSubmit: (t) => submitted.push(t),
197
+ })
198
+ ctrl.start()
199
+
200
+ input.write('hello world')
201
+ input.write('\r')
202
+
203
+ await tick()
204
+ assert.deepStrictEqual(submitted, ['hello world'])
205
+ })
206
+
207
+ test('独立 ESC 事件 + Enter(终端把 Alt+Enter 拆成两事件)也折行', async () => {
208
+ const { input, output } = makeEnv()
209
+ const submitted = []
210
+ const ctrl = createMultilineInput({
211
+ stdin: input, stdout: output, prompt: '> ',
212
+ onSubmit: (t) => submitted.push(t),
213
+ })
214
+ ctrl.start()
215
+
216
+ // 模拟终端把 Alt+Enter 拆成独立 ESC 事件和独立 \r 事件(分两次写入)
217
+ input.write('第一行')
218
+ input.write('\x1b') // 独立 ESC 事件
219
+ input.write('\r') // 随后的 Enter → 应视为 Alt+Enter 折行
220
+ input.write('第二行')
221
+ input.write('\r') // 普通 Enter 提交
222
+
223
+ await tick()
224
+ assert.deepStrictEqual(submitted, ['第一行\n第二行'])
225
+ })
226
+
227
+ test('CSI 序列 \\x1b[13~(部分终端的 Ctrl/Alt+Enter)折行', async () => {
228
+ const { input, output } = makeEnv()
229
+ const submitted = []
230
+ const ctrl = createMultilineInput({
231
+ stdin: input, stdout: output, prompt: '> ',
232
+ onSubmit: (t) => submitted.push(t),
233
+ })
234
+ ctrl.start()
235
+
236
+ input.write('第一行')
237
+ input.write('\x1b[13~') // CSI 序列 → f3 → 折行
238
+ input.write('第二行')
239
+ input.write('\r') // 普通 Enter 提交
240
+
241
+ await tick()
242
+ assert.deepStrictEqual(submitted, ['第一行\n第二行'])
243
+ })
@@ -16,6 +16,11 @@
16
16
  // MarkdownV2 安全编码
17
17
  // ============================================================
18
18
 
19
+ // 静态导入用于 offset 持久化(ESM 顶层导入,可在构造函数内同步使用)
20
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs'
21
+ import { homedir } from 'os'
22
+ import { join, dirname } from 'path'
23
+
19
24
  const TG_MD_ESCAPE_CHARS = /[_*[\]()~`>#+\-=|{}.!]/g
20
25
  const TG_CODE_ESCAPE_CHARS = /[`\\]/g
21
26
  const TG_LINK_ESCAPE_CHARS = /[()]/g
@@ -270,7 +275,8 @@ export class TelegramListener {
270
275
  this.proxyAddr = ch.proxy || process.env.CC_NODE_CHANNEL_TELEGRAM_PROXY || ''
271
276
  this.apiBase = ch.apiBase || ''
272
277
  this.bot = this.token ? new TelegramBotClient(this.token, { proxy: this.proxyAddr, apiBase: this.apiBase }) : null
273
- this.lastUpdateId = 0
278
+ // 从磁盘恢复持久化的 offset,避免进程重启后重放旧消息(含 /quit 等历史命令)
279
+ this.lastUpdateId = this._loadOffset()
274
280
  this.running = false
275
281
  this._pollTimer = null
276
282
  this._retryDelay = 1000
@@ -350,6 +356,9 @@ export class TelegramListener {
350
356
  if (data.result?.length) {
351
357
  for (const update of data.result) {
352
358
  this.lastUpdateId = Math.max(this.lastUpdateId, update.update_id)
359
+ // 推进 offset 后立即持久化到磁盘,保证进程异常退出(如 /quit)
360
+ // 重启后从正确位置继续,不重放旧消息。
361
+ this._saveOffset()
353
362
 
354
363
  // 处理回调查询(按钮点击)— 快速操作,直接 await
355
364
  if (update.callback_query) {
@@ -638,6 +647,77 @@ export class TelegramListener {
638
647
  _sleep(ms) {
639
648
  return new Promise(r => setTimeout(r, ms))
640
649
  }
650
+
651
+ // ============================================================
652
+ // Offset 持久化 — 防止进程重启后重放旧消息(含 /quit 等)
653
+ // Telegram 的 getUpdates 用 offset 确认消费;若 offset 只存内存,
654
+ // 进程异常退出(如 /quit 的 process.exit)后重启 lastUpdateId 归零,
655
+ // 会重放服务器上所有未确认的 update,导致 /quit 死循环起不来。
656
+ // 将 offset 持久化到磁盘,重启后从正确位置继续,彻底根治此问题。
657
+ // ============================================================
658
+
659
+ /** offset 状态文件路径 */
660
+ _offsetFile() {
661
+ return join(homedir(), '.cc-node', 'tg-offset.json')
662
+ }
663
+
664
+ /** 从磁盘加载已持久化的 offset */
665
+ _loadOffset() {
666
+ try {
667
+ const file = this._offsetFile()
668
+ if (!existsSync(file)) return 0
669
+ const raw = readFileSync(file, 'utf8').trim()
670
+ if (!raw) return 0
671
+ const data = JSON.parse(raw)
672
+ const id = Number(data.lastUpdateId) || 0
673
+ if (id > 0) log(`[TG] Restored offset ${id} (persisted, skip replayed updates)`)
674
+ return id
675
+ } catch {
676
+ return 0
677
+ }
678
+ }
679
+
680
+ /** 持久化当前 offset 到磁盘 */
681
+ _saveOffset() {
682
+ try {
683
+ const file = this._offsetFile()
684
+ mkdirSync(dirname(file), { recursive: true })
685
+ writeFileSync(file, JSON.stringify({ lastUpdateId: this.lastUpdateId, updatedAt: Date.now() }), 'utf8')
686
+ } catch (e) {
687
+ log(`[TG] Failed to persist offset: ${e.message}`)
688
+ }
689
+ }
690
+
691
+ /**
692
+ * 退出前确认消费已接收的 update(双保险)。
693
+ * 正常路径下 next poll 会提交 offset,但 /quit 等命令走 process.exit
694
+ * 同步强杀进程,没有机会再 poll 一次 → 未确认的 update 残留在服务器。
695
+ * 调用 getUpdates(offset=lastUpdateId+1) 可让 Telegram 立即确认消费。
696
+ * 返回 Promise,调用方应 await 后再退出。
697
+ */
698
+ async flushOffset() {
699
+ if (!this.bot) return
700
+ try {
701
+ const url = `${this.bot.apiBase}/getUpdates`
702
+ const res = await this._fetch(url, {
703
+ method: 'POST',
704
+ headers: { 'Content-Type': 'application/json' },
705
+ timeout: 10000,
706
+ body: JSON.stringify({
707
+ offset: this.lastUpdateId + 1,
708
+ timeout: 0,
709
+ allowed_updates: ['message', 'callback_query', 'edited_message'],
710
+ }),
711
+ })
712
+ if (res.ok) {
713
+ const data = await res.json()
714
+ if (data.ok) {
715
+ log(`[TG] Flushed offset ${this.lastUpdateId + 1} before exit`)
716
+ return
717
+ }
718
+ }
719
+ } catch {}
720
+ }
641
721
  }
642
722
 
643
723
  // ============================================================
package/src/core/cli.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * v1.2: 增加 Unix socket 服务,让 cc-notify 能发现并转发消息
6
6
  */
7
7
  import * as readline from 'readline'
8
+ import { createMultilineInput } from './multiline-input.js'
8
9
  import { createServer as createNetServer } from 'net'
9
10
  import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync, chmodSync } from 'fs'
10
11
  import { join } from 'path'
@@ -18,6 +19,7 @@ import { CostTracker } from './cost-tracker.js'
18
19
  import { autoCompact } from './compact.js'
19
20
  import { isLocalLlmServer } from '../utils/index.js'
20
21
  import { SOCK_DIR, SOCK_PATH, CC_NODE_PID } from './paths.js'
22
+ import { renderHeadpiece } from './headpiece.js'
21
23
  import { TelegramListener } from '../channel/tg-listener.js'
22
24
  import { fetchViaSocks5 } from '../channel/tg-proxy.js'
23
25
 
@@ -147,48 +149,16 @@ function buildBanner({ model, permissionMode, session, maxTokens }) {
147
149
  const top = `╭${leftLabel}${'─'.repeat(inner - leftLabel.length)}╮`
148
150
  const bottom = `╰${'─'.repeat(inner)}╯`
149
151
 
150
- const col1 = 24 // 左侧机器人列(19字符+边距)
152
+ const col1 = 24 // 左侧头标列(头标宽度 20 + 边距)
151
153
  const col2 = 50 // 标题列
152
154
  const col3 = inner - col1 - col2 - 2 // 信息列
153
155
 
154
- // ANSI 颜色
155
- const BLUE = '\x1b[34m'
156
- const CYAN = '\x1b[36m'
157
- const RESET = '\x1b[0m'
158
- const RED = '\x1b[31m'
159
-
160
- // 像素风格 CC 机器人(19字符宽,带颜色边框)
161
- const robotRaw = [
162
- ' ┌───────────────┐ ',
163
- ' │ ██ ██ │ ',
164
- ' │ ██████ │ ',
165
- ' │ ██ ██ │ ',
166
- ' │ │ ',
167
- ' │ ███████ │ ',
168
- ' │ ██ ██ │ ',
169
- ' │ ██████ │ ',
170
- ' │ ██ ██ ██ │ ',
171
- ' └───────────────┘ ',
172
- ]
173
-
174
- // 颜色化:边框蓝,眼睛/嘴巴青
175
- const colorize = (line) =>
176
- line
177
- .replace(/[┌└─╭╮┐┘│]/g, BLUE + '$&' + RESET)
178
- .replace(/[█]/g, RED + '$&' + RESET)
156
+ // 头标(独立模块 headpiece.js 提供,替换只需改该文件)
157
+ const headpiece = renderHeadpiece({ colWidth: col1 })
158
+ const robotLines = headpiece.lines
179
159
 
180
- const robotColored = robotRaw.map(colorize)
181
-
182
- // 机器人列:先去掉 ANSI(保证 pad 长度准确),再填充
160
+ // 去掉 ANSI 转义码,返回真实可见长度
183
161
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '')
184
- const robotPlain = robotColored.map(line => stripAnsi(line))
185
-
186
- // 在 col1 内居中(按最大可见长度 21 计算,短的自动靠右)
187
- const visibleWidth = 21
188
- const totalCol1 = 24
189
- const leftPad = Math.floor((totalCol1 - visibleWidth) / 2)
190
- const rightPad = totalCol1 - visibleWidth - leftPad
191
- const robotLines = robotPlain.map(line => ' '.repeat(leftPad) + line + ' '.repeat(rightPad))
192
162
 
193
163
  // 辅助函数:字符串填充(忽略 ANSI 颜色码计算真实长度,正确处理嵌入在字符串中的 ANSI)
194
164
  const realLen = (s) => stripAnsi(s).length
@@ -558,7 +528,8 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
558
528
  engine.config.onConfirmTool = async () => true
559
529
  }
560
530
  const result = await engine.processMessage(cliArgs.oneShot)
561
- console.log(result.response)
531
+ // 引擎已流式输出正文(无 onDelta 时引擎内部直写终端)→ 不再重复打印,避免"回答两次"
532
+ if (!engine.lastStreamed) console.log(result.response)
562
533
  // 保存会话
563
534
  session = await sessionManager.create(`one-shot: ${cliArgs.oneShot.slice(0, 50)}`)
564
535
  await sessionManager.appendMessage({ role: 'user', content: cliArgs.oneShot })
@@ -576,19 +547,37 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
576
547
  startSocketServer(engine, session, sessionManager, channelManager, verbose)
577
548
 
578
549
  // ============================================================
579
- // REPL 输入处理
550
+ // REPL 输入处理 — 多行输入(读取完整输入再处理)
580
551
  //
581
- // 使用标准 readline 的 line 事件(terminal: true)实现输入读取:
582
- // - 字符回显、退格删除、行回绕、方向键历史由 readline 原生处理,
583
- // 行为与 v2.5 一致,避免手动 keypress 回显导致的显示错乱。
584
- // - Enter 提交;Ctrl+C 退出。
585
- // - TTY(管道/重定向)模式复用同一 readline 接口。
552
+ // 不再用 readline 的 line 事件(它遇到 \n 就提交当前行,导致
553
+ // 多行文本被断句,后续行在引擎忙时被丢弃)。
554
+ // 改用 keypress + raw mode 自己管理输入缓冲(见 multiline-input.js):
555
+ // - Enter(\r)→ 提交整段输入(含内嵌换行)
556
+ // - Ctrl+Enter / Alt+Enter / Ctrl+J → 折行,多行输入(不提交)
557
+ // - 可打印字符回显、退格、上下方向键历史、Ctrl+C
558
+ // - 非 TTY(管道/重定向)回退到 readline line 事件
586
559
  // ============================================================
587
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' })
560
+ const inputCtrl = createMultilineInput({
561
+ prompt: '> ',
562
+ onSubmit: (text) => { processInputLine(text) },
563
+ onExit: () => { process.exit(0) },
564
+ })
588
565
 
589
566
  // 显示提示符
590
567
  function showPrompt() {
591
- rl.prompt()
568
+ inputCtrl.showPrompt()
569
+ }
570
+
571
+ // 单行问题收集(权限确认 / /models 选择 / AskUserQuestion)
572
+ // 返回 Promise<string>,在 keypress 模式下与多行输入共用同一 stdin,
573
+ // 避免 readline 的 question 与主循环 keypress 互相干扰。
574
+ function askQuestion(questionText) {
575
+ return inputCtrl.ask(questionText)
576
+ }
577
+
578
+ // 兼容对象:供 AskUserQuestion 工具和引擎的 ctx.readline.question() 使用
579
+ const rl = {
580
+ question: (q, cb) => { askQuestion(q).then(cb) },
592
581
  }
593
582
 
594
583
  // Telegram 双向通道(可选):tgListener 监听 Telegram 消息,tgChatId 记录回复目标
@@ -603,6 +592,8 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
603
592
  let modelList = []
604
593
  // /models 从 Telegram 发出后,等待用户回复编号/名字来选择模型
605
594
  let pendingModelSelect = false
595
+ // 流式输出标志 — onDelta 已实时把回答输出到终端时,不再重复打印完整 response
596
+ let streamedText = false
606
597
 
607
598
  // 处理输入行
608
599
  // source: 'cli' 来自终端输入, 'telegram' 来自 Telegram
@@ -898,9 +889,15 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
898
889
  }
899
890
  break
900
891
  }
901
- case 'exit': case 'quit':
892
+ case 'exit': case 'quit': {
893
+ // 退出前确认消费 Telegram update(避免 /quit 等命令残留在服务器缓冲区,
894
+ // 下次启动重放导致死循环起不来)。配合 tg-listener 的 offset 持久化双保险。
895
+ if (tgListener?.bot) {
896
+ try { await tgListener.flushOffset() } catch {}
897
+ }
902
898
  console.log('Goodbye!')
903
899
  process.exit(0)
900
+ }
904
901
  default:
905
902
  console.log(`Unknown command: /${cmd}. Type /help for available commands.`)
906
903
  }
@@ -934,7 +931,11 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
934
931
  // 处理结束,清掉思维链流(最终回复随后发送)
935
932
  tgThinkingEnd()
936
933
  console.log()
937
- console.log(result.response)
934
+ // 正文已被 onDelta 实时流式输出到终端 → 不重复打印(否则出现"回答两次"的双层显示)。
935
+ // 仅当没有流式输出(noStream / 流式失败 / 无 onDelta 回调)时才打印完整 response。
936
+ if (!engine.lastStreamed && !streamedText) {
937
+ console.log(result.response)
938
+ }
938
939
  console.log()
939
940
  await sessionManager.appendMessage({ role: 'user', content: input })
940
941
  await sessionManager.appendMessage({ role: 'assistant', content: result.response })
@@ -1056,12 +1057,11 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
1056
1057
  tgThinking.flushing = false
1057
1058
  }
1058
1059
 
1059
- // REPL 主循环 — 由 readline 原生处理回显、退格、行回绕与 Enter 提交
1060
- rl.on('line', (line) => {
1061
- processInputLine(line)
1062
- })
1060
+ // REPL 主循环 — 由 multiline-input 处理多行输入(keypress / 非TTY readline)
1061
+ // 输入由 createMultilineInput 在创建时挂接,start() 显示初始提示符
1062
+ inputCtrl.start()
1063
1063
 
1064
- // 将 readline 注入引擎配置,用于 ask 模式确认和 AskUserQuestion 工具
1064
+ // 将 readline 兼容对象注入引擎配置,用于 ask 模式确认和 AskUserQuestion 工具
1065
1065
  if (permissionMode === 'ask') {
1066
1066
  engine.config.onConfirmTool = async (toolName, input) => {
1067
1067
  // 如果已经启用会话全局自动允许,直接通过
@@ -1177,6 +1177,8 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
1177
1177
  engine.config.onDelta = ({ type, text }) => {
1178
1178
  // 保持 CLI 实时输出
1179
1179
  process.stdout.write(text)
1180
+ // 记录已有文本被实时输出到终端(供 processInputLine 判断是否需重复打印 response)
1181
+ if (type === 'text' && text) streamedText = true
1180
1182
  // 无论 text 还是 reasoning,都实时推送到 Telegram(节流)
1181
1183
  tgThinkingPush(text)
1182
1184
  }
@@ -0,0 +1,91 @@
1
+ // ============================================================
2
+ // headpiece.js — CC-Node 启动头标(独立模块)
3
+ // ------------------------------------------------------------
4
+ // 只负责「头标」的绘制与配色,与 CLI 框架解耦。
5
+ // 以后要替换/升级头标,只需修改本文件并保持接口不变即可。
6
+ //
7
+ // 头标内容: CC-NODE 艺术字
8
+ // CC : 两个 C, 10列宽 x 4行高 (扁平, 蓝渐变)
9
+ // NODE : 四个字母, 5列宽 x 4行高 (绿渐变)
10
+ // 上下严格对齐 (20列 x 8行)
11
+ //
12
+ // 接口:
13
+ // renderHeadpiece({ colWidth = 24 } = {})
14
+ // -> { lines: string[], height: number, width: number }
15
+ // lines 已上色、并已居中填充到 colWidth 宽度的行数组
16
+ // height 头标实际行数(未填充前的原始行数)
17
+ // width 头标原始可见宽度
18
+ // ============================================================
19
+
20
+ // ---- 头标原始数据(20列 x 8行)----
21
+ // CC 部分(蓝渐变, 4行)
22
+ const CC = [
23
+ '╔════════╗╔════════╗',
24
+ '██║ ██║ ',
25
+ '██║ ██║ ',
26
+ '╚════════╝╚════════╝',
27
+ ]
28
+
29
+ // NODE 部分(绿渐变, 4行)
30
+ const NODE = [
31
+ '╔╗╔═╗╔═══╗╔═══╗╔═══╗',
32
+ '║║║ ║║ ║║ ║║ ║',
33
+ '║╚╝ ║║ ║║ ║╠═══╣',
34
+ '╚═══╝╚═══╝╚═══╝╚═══╝',
35
+ ]
36
+
37
+ // ---- 配色(渐变)----
38
+ // CC 蓝色渐变(深 → 浅)
39
+ const BLUE = [81, 75, 69, 63]
40
+ // NODE 绿色渐变(深 → 浅)
41
+ const GREEN = [82, 118, 154, 190]
42
+
43
+ // 给每一行按对应色号上色
44
+ function colorize(lines, colors) {
45
+ return lines.map((line, i) => `\x1b[38;5;${colors[i]}m${line}\x1b[0m`)
46
+ }
47
+
48
+ // 去掉 ANSI 转义码,返回真实可见长度
49
+ function stripAnsi(s) {
50
+ return s.replace(/\x1b\[[0-9;]*m/g, '')
51
+ }
52
+
53
+ /**
54
+ * 渲染头标。
55
+ * @param {Object} [opts]
56
+ * @param {number} [opts.colWidth=24] 目标列宽(用于居中填充,须 >= 头标宽度)
57
+ * @returns {{ lines: string[], height: number, width: number }}
58
+ */
59
+ export function renderHeadpiece({ colWidth = 24 } = {}) {
60
+ // 1. 原始可见宽度
61
+ const width = stripAnsi(CC[0]).length // 20
62
+
63
+ // 2. 合并 CC + NODE,分别上色
64
+ const rawLines = [...colorize(CC, BLUE), ...colorize(NODE, GREEN)]
65
+ const height = rawLines.length // 8
66
+
67
+ // 3. 在 colWidth 内居中填充(若 colWidth < width 则不做填充,原样返回)
68
+ if (colWidth <= width) {
69
+ return { lines: rawLines, height, width }
70
+ }
71
+
72
+ const totalPad = colWidth - width
73
+ const leftPad = Math.floor(totalPad / 2)
74
+ const rightPad = totalPad - leftPad
75
+
76
+ const lines = rawLines.map((line) => {
77
+ const leadingAnsi = line.match(/^(\x1b\[[0-9;]*m)+/)?.[0] || ''
78
+ const trailingAnsi = line.match(/(\x1b\[0m)+$/)?.[0] || '\x1b[0m'
79
+ const plain = stripAnsi(line)
80
+ return leadingAnsi + ' '.repeat(leftPad) + plain + ' '.repeat(rightPad) + trailingAnsi
81
+ })
82
+
83
+ return { lines, height, width }
84
+ }
85
+
86
+ // 允许命令行直接预览:node src/core/headpiece.js
87
+ const isDirectRun = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop())
88
+ if (isDirectRun) {
89
+ const { lines } = renderHeadpiece({ colWidth: 24 })
90
+ console.log(`\n${lines.join('\n')}\n`)
91
+ }
@@ -0,0 +1,301 @@
1
+ // ============================================================
2
+ // multiline-input.js — REPL 多行输入(读取完整输入再处理)
3
+ // ------------------------------------------------------------
4
+ // 解决「输入缓存未读完就按 \n 断句」的根因问题:
5
+ // readline 的 line 事件遇到 \n 就提交当前行,导致多行文本
6
+ // 被拆成多段,且后续行在引擎忙时被丢弃。
7
+ //
8
+ // 本模块改用 keypress + raw mode 自己管理输入缓冲:
9
+ // - Enter(\r / CR) → 提交整段输入(含内嵌换行)
10
+ // - Ctrl+Enter / Alt+Enter / Ctrl+J → 折行,累积到缓冲,不提交(多行输入)
11
+ // - 可打印字符 → 回显并累积
12
+ // - 退格 → 删除并回显
13
+ // - 上/下方向键 → 浏览输入历史
14
+ // - Ctrl+C → 清空 / 退出
15
+ // - 非 TTY(管道/重定向) → 回退到 readline line 事件
16
+ //
17
+ // 接口:
18
+ // createMultilineInput({ prompt, onSubmit, onExit, stdin, stdout })
19
+ // -> { start(), showPrompt(), ask(questionText), dispose() }
20
+ // onSubmit(text) 完整输入(可能含 \n)提交回调
21
+ // ask(qText) 单行问题收集(返回 Promise<string>)
22
+ // ============================================================
23
+
24
+ import * as readline from 'readline'
25
+
26
+ export function createMultilineInput({ prompt = '> ', onSubmit, onExit, stdin, stdout } = {}) {
27
+ const input = stdin || process.stdin
28
+ const output = stdout || process.stdout
29
+ const isTTY = input.isTTY
30
+
31
+ let inputBuffer = '' // 当前多行输入缓冲
32
+ let inputHistory = [] // 输入历史
33
+ let historyIndex = -1 // 历史浏览索引(-1 = 编辑新输入)
34
+ let skipNextLF = false // CRLF 提交后忽略残留 \n
35
+ let questioning = false // 是否正在收集单行问题(权限确认等)
36
+ let questionResolve = null
37
+ let questionBuf = ''
38
+ let escPending = false // 刚收到独立 ESC 事件(某些终端把 Alt+Enter 拆成 ESC 和 Enter 两个事件)
39
+ let escTimer = null // ESC 后跟 Enter 的超时还原定时器
40
+
41
+ const PROMPT = prompt
42
+ let displayedRows = 0 // 当前输入区在终端实际占用的屏幕行数(含提示符行,考虑换行与CJK宽字符)
43
+
44
+ // ============================================================
45
+ // 非 TTY 模式:回退到 readline line 事件(管道/重定向)
46
+ // ============================================================
47
+ if (!isTTY) {
48
+ const rl = readline.createInterface({ input, output, prompt })
49
+ return {
50
+ start() { rl.on('line', (line) => onSubmit(line)); rl.prompt() },
51
+ showPrompt() { rl.prompt() },
52
+ ask(q) {
53
+ return new Promise((resolve) => { rl.question(q + ' ', resolve) })
54
+ },
55
+ dispose() { rl.close() },
56
+ }
57
+ }
58
+
59
+ // ============================================================
60
+ // TTY 模式:keypress + raw mode 多行输入
61
+ // ============================================================
62
+ readline.emitKeypressEvents(input)
63
+ try { input.setRawMode(true) } catch {}
64
+
65
+ // 终端列数(缺省 80)
66
+ function cols() {
67
+ return (output.columns && output.columns > 0) ? output.columns : 80
68
+ }
69
+
70
+ // 单字符在终端占用的列宽(CJK/全角 = 2,其余 = 1)
71
+ function charWidth(ch) {
72
+ const code = ch.codePointAt(0)
73
+ if (
74
+ (code >= 0x1100 && code <= 0x115F) ||
75
+ (code >= 0x2E80 && code <= 0x303E) ||
76
+ (code >= 0x3041 && code <= 0x33FF) ||
77
+ (code >= 0x3400 && code <= 0x4DBF) ||
78
+ (code >= 0x4E00 && code <= 0x9FFF) ||
79
+ (code >= 0xA000 && code <= 0xA4CF) ||
80
+ (code >= 0xAC00 && code <= 0xD7A3) ||
81
+ (code >= 0xF900 && code <= 0xFAFF) ||
82
+ (code >= 0xFE30 && code <= 0xFE4F) ||
83
+ (code >= 0xFF00 && code <= 0xFF60) ||
84
+ (code >= 0xFFE0 && code <= 0xFFE6)
85
+ ) return 2
86
+ return 1
87
+ }
88
+
89
+ // 文本可见列宽(按 CJK 宽字符计数)
90
+ function visibleWidth(text) {
91
+ let w = 0
92
+ for (const ch of text) w += charWidth(ch)
93
+ return w
94
+ }
95
+
96
+ // 渲染文本占用的终端屏幕行数(考虑自动换行;每逻辑行至少 1 行)
97
+ function renderedRows(text) {
98
+ const c = cols()
99
+ let rows = 0
100
+ for (const segment of text.split('\n')) {
101
+ const w = visibleWidth(segment)
102
+ rows += Math.max(1, Math.ceil(w / c))
103
+ }
104
+ return rows
105
+ }
106
+
107
+ // 生成要显示的文本(提示符 + 输入缓冲;换行后按提示符宽度缩进)
108
+ function displayText() {
109
+ return PROMPT + inputBuffer.replace(/\n/g, '\n' + ' '.repeat(PROMPT.length))
110
+ }
111
+
112
+ // 清空并重绘整个输入区(保证回显稳定、光标不乱跳)
113
+ // - 先把光标移回输入区首行行首
114
+ // - 清到屏尾,重绘全部内容
115
+ // - 重绘后光标自然停在输入末尾
116
+ function render() {
117
+ // 上移到输入区首行(若是多行);单行时不动
118
+ if (displayedRows > 1) output.write(`\x1b[${displayedRows - 1}A`)
119
+ output.write('\r') // 回到首行行首
120
+ output.write('\x1b[J') // 清到屏尾
121
+
122
+ const rendered = displayText()
123
+ output.write(rendered)
124
+ displayedRows = renderedRows(rendered)
125
+ }
126
+
127
+ function showPrompt() {
128
+ output.write(PROMPT)
129
+ displayedRows = 1
130
+ }
131
+
132
+ function submit() {
133
+ const inputText = inputBuffer
134
+ inputBuffer = ''
135
+ skipNextLF = true
136
+ displayedRows = 0
137
+ output.write('\n')
138
+ if (inputText.trim()) {
139
+ inputHistory.push(inputText)
140
+ historyIndex = inputHistory.length
141
+ }
142
+ if (onSubmit) onSubmit(inputText)
143
+ else showPrompt()
144
+ }
145
+
146
+ function loadHistory(dir) {
147
+ if (inputHistory.length === 0) return
148
+ if (dir === -1) {
149
+ if (historyIndex <= 0) { historyIndex = 0; inputBuffer = inputHistory[0]; render(); return }
150
+ historyIndex--
151
+ } else {
152
+ if (historyIndex >= inputHistory.length) return
153
+ historyIndex++
154
+ }
155
+ inputBuffer = historyIndex < inputHistory.length ? inputHistory[historyIndex] : ''
156
+ render()
157
+ }
158
+
159
+ function onKeypress(str, key) {
160
+ if (!key) return
161
+
162
+ // 正在收集单行问题(权限确认 / AskUserQuestion)
163
+ if (questioning) {
164
+ if (key.ctrl && key.name === 'c') {
165
+ output.write('\n')
166
+ const r = questionResolve; questionResolve = null; questioning = false
167
+ if (r) r('')
168
+ return
169
+ }
170
+ if (key.name === 'return' || key.name === 'enter') {
171
+ output.write('\n')
172
+ const ans = questionBuf; questionBuf = ''
173
+ const r = questionResolve; questionResolve = null; questioning = false
174
+ if (r) r(ans)
175
+ return
176
+ }
177
+ if (key.name === 'backspace') {
178
+ questionBuf = questionBuf.slice(0, -1)
179
+ output.write('\b \b')
180
+ return
181
+ }
182
+ if (str && key.name !== 'enter') {
183
+ questionBuf += str
184
+ output.write(str)
185
+ }
186
+ return
187
+ }
188
+
189
+ // Ctrl+C
190
+ if (key.ctrl && key.name === 'c') {
191
+ if (inputBuffer) {
192
+ inputBuffer = ''
193
+ displayedRows = 0
194
+ output.write('\n')
195
+ showPrompt()
196
+ } else {
197
+ output.write('\nGoodbye!\n')
198
+ if (onExit) onExit()
199
+ }
200
+ return
201
+ }
202
+
203
+ // ESC 键 — 单独出现(未与后续字符合并)时,标记 escPending,
204
+ // 下一个 Enter 视为 Alt+Enter → 折行(兼容部分终端把 Alt+Enter 拆成两个事件)
205
+ if (key.name === 'escape') {
206
+ escPending = true
207
+ // 清掉 pending 状态:若后续 300ms 无 Enter 则还原,避免误把普通 Enter 当折行
208
+ if (escTimer) clearTimeout(escTimer)
209
+ escTimer = setTimeout(() => { escPending = false }, 400)
210
+ return
211
+ }
212
+
213
+ // Enter / 换行
214
+ //
215
+ // 提交 vs 折行判定:
216
+ // - 普通 Enter(\r,无 ctrl/meta) → 提交整段输入
217
+ // - Ctrl+Enter(部分终端发送 ctrl+return) → 折行(多行输入)
218
+ // - Alt+Enter / Esc+Enter(meta+return) → 折行(跨终端可靠)
219
+ // - Ctrl+J / 字面 \n(key.name === enter) → 折行
220
+ // - 某些终端的 Ctrl/Alt+Enter 发送 CSI 序列 \x1b[13~(key.name === 'f3')→ 折行
221
+ //
222
+ // 注意:多数终端按下 Ctrl+Enter 发送的仍是 \r,与普通 Enter 无法区分,
223
+ // 因此跨终端可靠的多行换行键是 Alt+Enter(Esc+Enter)和 Ctrl+J(\n)。两者都支持。
224
+ if (key.name === 'return' || key.name === 'enter' || key.name === 'f3') {
225
+ // 紧跟 ESC 的 Enter → 视为 Alt+Enter,折行
226
+ if (escPending) {
227
+ escPending = false
228
+ if (escTimer) { clearTimeout(escTimer); escTimer = null }
229
+ inputBuffer += '\n'
230
+ render()
231
+ return
232
+ }
233
+ // CSI 序列 \x1b[13~(某些终端的 Ctrl/Alt+Enter)→ 折行
234
+ if (key.name === 'f3') {
235
+ inputBuffer += '\n'
236
+ render()
237
+ return
238
+ }
239
+ if (key.name === 'enter') {
240
+ // 字面换行 \n / Ctrl+J → 折行累积
241
+ if (skipNextLF) { skipNextLF = false; return } // CRLF 残留 \n
242
+ inputBuffer += '\n'
243
+ render()
244
+ } else {
245
+ // key.name === 'return'(\r)
246
+ if (key.ctrl || key.meta) {
247
+ // Ctrl+Enter 或 Alt+Enter/Esc+Enter → 折行(多行输入)
248
+ if (skipNextLF) { skipNextLF = false; return }
249
+ inputBuffer += '\n'
250
+ render()
251
+ } else {
252
+ submit() // 普通 Enter → 提交
253
+ }
254
+ }
255
+ return
256
+ }
257
+
258
+ // 非 Enter 键输入会清除 escPending(ESC 后跟普通字符不是 Alt+Enter)
259
+ if (escPending && str) {
260
+ escPending = false
261
+ if (escTimer) { clearTimeout(escTimer); escTimer = null }
262
+ }
263
+
264
+ // 退格
265
+ if (key.name === 'backspace') {
266
+ if (inputBuffer.length > 0) {
267
+ inputBuffer = inputBuffer.slice(0, -1)
268
+ render()
269
+ }
270
+ return
271
+ }
272
+
273
+ // 方向键上/下:历史
274
+ if (key.name === 'up') { loadHistory(-1); return }
275
+ if (key.name === 'down') { loadHistory(1); return }
276
+
277
+ // 普通可打印字符(含中文,str 为完整字符)
278
+ if (str && !key.ctrl && !key.meta) {
279
+ inputBuffer += str
280
+ render()
281
+ }
282
+ }
283
+
284
+ input.on('keypress', onKeypress)
285
+
286
+ function ask(q) {
287
+ return new Promise((resolve) => {
288
+ output.write('\n' + q + ' ')
289
+ questioning = true
290
+ questionResolve = resolve
291
+ questionBuf = ''
292
+ })
293
+ }
294
+
295
+ function dispose() {
296
+ try { input.setRawMode(false) } catch {}
297
+ input.removeListener('keypress', onKeypress)
298
+ }
299
+
300
+ return { start: showPrompt, showPrompt, ask, dispose }
301
+ }
@@ -63,6 +63,8 @@ export class QueryEngine {
63
63
  this.abortController = null
64
64
  this.costTracker = this.config.costTracker || new CostTracker({ model: this.config.model })
65
65
  this.tokenBudget = this.config.tokenBudget || null
66
+ // 最近一次 processMessage 是否已通过流式回调/直写把正文输出(供调用方避免重复打印 response)
67
+ this.lastStreamed = false
66
68
  }
67
69
 
68
70
  /**
@@ -76,6 +78,7 @@ export class QueryEngine {
76
78
  }
77
79
  this.state.isRunning = true
78
80
  this.state.turnCount++
81
+ this.lastStreamed = false // 本轮是否已流式输出正文
79
82
  this.abortController = new AbortController()
80
83
  const userMsg = new UserMessage(userInput, images)
81
84
  this.state.messages.push(userMsg)
@@ -405,6 +408,8 @@ export class QueryEngine {
405
408
  try {
406
409
  for await (const event of parseStream(response)) {
407
410
  if (event.type === 'text') {
411
+ // 记录已流式输出正文(供调用方避免重复打印最终 response)
412
+ if (event.text) this.lastStreamed = true
408
413
  // 实时输出(有 onDelta 回调时交给调用方,如 VS Code 扩展;否则写终端)
409
414
  if (typeof this.config.onDelta === 'function') {
410
415
  this.config.onDelta({ type: 'text', text: event.text })