@deepwhale/coding-agent 1.0.12 → 1.0.13
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/agent/agent-compaction.d.ts +74 -0
- package/dist/agent/agent-compaction.d.ts.map +1 -0
- package/dist/agent/agent-compaction.js +145 -0
- package/dist/agent/agent-compaction.js.map +1 -0
- package/dist/agent/index.d.ts +16 -0
- package/dist/agent/index.d.ts.map +1 -0
- package/dist/agent/index.js +17 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/agent/session-adapter.d.ts +177 -0
- package/dist/agent/session-adapter.d.ts.map +1 -0
- package/dist/agent/session-adapter.js +365 -0
- package/dist/agent/session-adapter.js.map +1 -0
- package/dist/agent/tool-loop.d.ts +123 -0
- package/dist/agent/tool-loop.d.ts.map +1 -0
- package/dist/agent/tool-loop.js +436 -0
- package/dist/agent/tool-loop.js.map +1 -0
- package/dist/env/load-project-env.d.ts +40 -0
- package/dist/env/load-project-env.d.ts.map +1 -0
- package/dist/env/load-project-env.js +80 -0
- package/dist/env/load-project-env.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/llm-factory.d.ts +50 -0
- package/dist/llm-factory.d.ts.map +1 -0
- package/dist/llm-factory.js +110 -0
- package/dist/llm-factory.js.map +1 -0
- package/dist/modes/index.d.ts +14 -0
- package/dist/modes/index.d.ts.map +1 -0
- package/dist/modes/index.js +14 -0
- package/dist/modes/index.js.map +1 -0
- package/dist/modes/print.d.ts +50 -0
- package/dist/modes/print.d.ts.map +1 -0
- package/dist/modes/print.js +236 -0
- package/dist/modes/print.js.map +1 -0
- package/dist/modes/rpc.d.ts +52 -0
- package/dist/modes/rpc.d.ts.map +1 -0
- package/dist/modes/rpc.js +316 -0
- package/dist/modes/rpc.js.map +1 -0
- package/dist/modes/tui.d.ts +107 -0
- package/dist/modes/tui.d.ts.map +1 -0
- package/dist/modes/tui.js +680 -0
- package/dist/modes/tui.js.map +1 -0
- package/dist/policy/args-digest.d.ts +13 -0
- package/dist/policy/args-digest.d.ts.map +1 -0
- package/dist/policy/args-digest.js +29 -0
- package/dist/policy/args-digest.js.map +1 -0
- package/dist/policy/chain.d.ts +19 -0
- package/dist/policy/chain.d.ts.map +1 -0
- package/dist/policy/chain.js +24 -0
- package/dist/policy/chain.js.map +1 -0
- package/dist/policy/index.d.ts +17 -0
- package/dist/policy/index.d.ts.map +1 -0
- package/dist/policy/index.js +16 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/sanitize-reason.d.ts +11 -0
- package/dist/policy/sanitize-reason.d.ts.map +1 -0
- package/dist/policy/sanitize-reason.js +24 -0
- package/dist/policy/sanitize-reason.js.map +1 -0
- package/dist/policy/static-rules.d.ts +32 -0
- package/dist/policy/static-rules.d.ts.map +1 -0
- package/dist/policy/static-rules.js +106 -0
- package/dist/policy/static-rules.js.map +1 -0
- package/dist/policy/types.d.ts +56 -0
- package/dist/policy/types.d.ts.map +1 -0
- package/dist/policy/types.js +13 -0
- package/dist/policy/types.js.map +1 -0
- package/dist/repl/repl-command-router.d.ts +78 -0
- package/dist/repl/repl-command-router.d.ts.map +1 -0
- package/dist/repl/repl-command-router.js +112 -0
- package/dist/repl/repl-command-router.js.map +1 -0
- package/dist/repl/repl-confirm.d.ts +49 -0
- package/dist/repl/repl-confirm.d.ts.map +1 -0
- package/dist/repl/repl-confirm.js +88 -0
- package/dist/repl/repl-confirm.js.map +1 -0
- package/dist/repl/repl-session.d.ts +79 -0
- package/dist/repl/repl-session.d.ts.map +1 -0
- package/dist/repl/repl-session.js +129 -0
- package/dist/repl/repl-session.js.map +1 -0
- package/dist/repl/repl-signal-coordinator.d.ts +74 -0
- package/dist/repl/repl-signal-coordinator.d.ts.map +1 -0
- package/dist/repl/repl-signal-coordinator.js +73 -0
- package/dist/repl/repl-signal-coordinator.js.map +1 -0
- package/dist/repl.d.ts +117 -0
- package/dist/repl.d.ts.map +1 -0
- package/dist/repl.js +626 -0
- package/dist/repl.js.map +1 -0
- package/dist/sandbox/docker-runner.d.ts +147 -0
- package/dist/sandbox/docker-runner.d.ts.map +1 -0
- package/dist/sandbox/docker-runner.js +426 -0
- package/dist/sandbox/docker-runner.js.map +1 -0
- package/dist/sandbox/env-gate.d.ts +28 -0
- package/dist/sandbox/env-gate.d.ts.map +1 -0
- package/dist/sandbox/env-gate.js +65 -0
- package/dist/sandbox/env-gate.js.map +1 -0
- package/dist/sandbox/local-runner.d.ts +29 -0
- package/dist/sandbox/local-runner.d.ts.map +1 -0
- package/dist/sandbox/local-runner.js +79 -0
- package/dist/sandbox/local-runner.js.map +1 -0
- package/dist/sandbox/types.d.ts +80 -0
- package/dist/sandbox/types.d.ts.map +1 -0
- package/dist/sandbox/types.js +25 -0
- package/dist/sandbox/types.js.map +1 -0
- package/dist/tools/bash.d.ts +35 -0
- package/dist/tools/bash.d.ts.map +1 -0
- package/dist/tools/bash.js +233 -0
- package/dist/tools/bash.js.map +1 -0
- package/dist/tools/edit-file.d.ts +22 -0
- package/dist/tools/edit-file.d.ts.map +1 -0
- package/dist/tools/edit-file.js +79 -0
- package/dist/tools/edit-file.js.map +1 -0
- package/dist/tools/find.d.ts +21 -0
- package/dist/tools/find.d.ts.map +1 -0
- package/dist/tools/find.js +168 -0
- package/dist/tools/find.js.map +1 -0
- package/dist/tools/grep.d.ts +19 -0
- package/dist/tools/grep.d.ts.map +1 -0
- package/dist/tools/grep.js +170 -0
- package/dist/tools/grep.js.map +1 -0
- package/dist/tools/index.d.ts +10 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +10 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/read-file.d.ts +18 -0
- package/dist/tools/read-file.d.ts.map +1 -0
- package/dist/tools/read-file.js +52 -0
- package/dist/tools/read-file.js.map +1 -0
- package/dist/tools/registry.d.ts +39 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +67 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/write-file.d.ts +18 -0
- package/dist/tools/write-file.d.ts.map +1 -0
- package/dist/tools/write-file.js +47 -0
- package/dist/tools/write-file.js.map +1 -0
- package/dist/tui-ink-bundle.js +38587 -0
- package/dist/types.d.ts +89 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/util/index.d.ts +16 -0
- package/dist/util/index.d.ts.map +1 -0
- package/dist/util/index.js +16 -0
- package/dist/util/index.js.map +1 -0
- package/dist/util/tui-history.d.ts +37 -0
- package/dist/util/tui-history.d.ts.map +1 -0
- package/dist/util/tui-history.js +93 -0
- package/dist/util/tui-history.js.map +1 -0
- package/dist/verify/format-report.d.ts +57 -0
- package/dist/verify/format-report.d.ts.map +1 -0
- package/dist/verify/format-report.js +128 -0
- package/dist/verify/format-report.js.map +1 -0
- package/dist/verify/index.d.ts +8 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +8 -0
- package/dist/verify/index.js.map +1 -0
- package/dist/verify/verify-runner.d.ts +186 -0
- package/dist/verify/verify-runner.d.ts.map +1 -0
- package/dist/verify/verify-runner.js +707 -0
- package/dist/verify/verify-runner.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI 模式 — Sprint 1c-revive-4-D-20.3 P0-B (2026-06-05) v1.0 capability completion
|
|
3
|
+
*
|
|
4
|
+
* @deprecated LEGACY — Sprint 1c-revive-2-D-24.3 (2026-06-06) v1.0.9:
|
|
5
|
+
* 1.0.9+ `deepwhale tui` mode 走 Ink 容器 (@deepwhale/tui-ink 1.0.9 bundle).
|
|
6
|
+
* 本文件保留仅作 source-install dev 路径保底, 不再是 default path.
|
|
7
|
+
* - bin/deepwhale.js 'tui' case → runTuiInkMode (D-24.3 dispatch)
|
|
8
|
+
* - 跟 D-24.1 §6 红线 1 一致: legacy 0 删, fallback 兜底
|
|
9
|
+
* - 现有 979 行 tui-smoke.test.ts 0 改动, 行为在 readline 容器仍验过
|
|
10
|
+
*
|
|
11
|
+
* 业务逻辑 0 重写: D-22 / D-23.1 / D-23.2 业务 1:1 搬到 tui-ink/, 跟本文件同形态.
|
|
12
|
+
* 不再维护 (sprint 1.1+): 计划完全删除本文件, 但保留 0 改动作 fallback.
|
|
13
|
+
*
|
|
14
|
+
* ---- 下方为 v1.0.0 - v1.0.8 readline 容器实现 ----
|
|
15
|
+
*
|
|
16
|
+
* Minimal ANSI TUI. **不**装新依赖 (无 Ink), 用 node:readline + ANSI 转义码.
|
|
17
|
+
*
|
|
18
|
+
* 复用红线 (D-20.3 P0-B 拍板):
|
|
19
|
+
* - 复用 runToolLoop + staticToolPolicy (不绕过 ToolPolicy)
|
|
20
|
+
* - 复用 createReplConfirm (D-19 串行化, 不重建 2 套 confirm)
|
|
21
|
+
* - 复用 SessionWriter (不绕过 session audit, 跟 REPL/print mode 同形态)
|
|
22
|
+
* - 复用 formatUsageStatus (REPL 状态栏 4 字段, 风格统一)
|
|
23
|
+
*
|
|
24
|
+
* 必须实现 (用户红线):
|
|
25
|
+
* 1. `deepwhale tui` 启动
|
|
26
|
+
* 2. 用户可输入 prompt
|
|
27
|
+
* 3. assistant stream 可显示
|
|
28
|
+
* 4. tool call / result 可显示
|
|
29
|
+
* 5. destructive tool 触发 y/N confirm (走 createReplConfirm)
|
|
30
|
+
* 6. y 执行, n/empty 拒绝
|
|
31
|
+
* 7. /exit 或 q 退出
|
|
32
|
+
* 8. session 不损坏 (走 D-19.5 finish 路径, writer.close)
|
|
33
|
+
* 9. TUI 路径复用 ToolPolicy / SessionWriter / runToolLoop
|
|
34
|
+
*
|
|
35
|
+
* Minimal scope (v1.0):
|
|
36
|
+
* - ANSI 颜色: 标题 / 用户 prompt / tool call / tool result / 状态栏
|
|
37
|
+
* - 不做: 多行 / 自动补全 / 主题 / 鼠标 / 文件树 / syntax highlight
|
|
38
|
+
*
|
|
39
|
+
* NOT covered (defer to v1.1):
|
|
40
|
+
* - 全屏 IDE-style TUI
|
|
41
|
+
* - 主题切换
|
|
42
|
+
* - 多 session 切换
|
|
43
|
+
* - Plan mode / recovery
|
|
44
|
+
*/
|
|
45
|
+
import { createInterface } from 'node:readline';
|
|
46
|
+
import { stdin, stdout, stderr } from 'node:process';
|
|
47
|
+
import { SessionReader, SessionWriter } from '@deepwhale/core';
|
|
48
|
+
import { tuiHistoryLoad, tuiHistoryAppend, } from '../util/tui-history.js';
|
|
49
|
+
import { isToolLoopError, loadSession, persistToolLoopSteps, runToolLoop, } from '../agent/index.js';
|
|
50
|
+
import { createDefaultRegistry } from '../tools/registry.js';
|
|
51
|
+
import { formatUsageStatus } from '../repl.js';
|
|
52
|
+
import { createDefaultClient } from '../llm-factory.js';
|
|
53
|
+
import { resolveSandboxRunnerFromEnv } from '../sandbox/env-gate.js';
|
|
54
|
+
import { staticToolPolicy } from '../policy/static-rules.js';
|
|
55
|
+
import { createReplConfirm } from '../repl/repl-confirm.js'; // D-19: 复用 REPL confirm controller
|
|
56
|
+
// ---- ANSI 颜色 (no dependency, 直接 escape) ----
|
|
57
|
+
const ANSI = {
|
|
58
|
+
reset: '\x1b[0m',
|
|
59
|
+
bold: '\x1b[1m',
|
|
60
|
+
dim: '\x1b[2m',
|
|
61
|
+
// 前景
|
|
62
|
+
cyan: '\x1b[36m',
|
|
63
|
+
green: '\x1b[32m',
|
|
64
|
+
yellow: '\x1b[33m',
|
|
65
|
+
red: '\x1b[31m',
|
|
66
|
+
blue: '\x1b[34m',
|
|
67
|
+
magenta: '\x1b[35m',
|
|
68
|
+
// 背景
|
|
69
|
+
bgBlue: '\x1b[44m',
|
|
70
|
+
};
|
|
71
|
+
/** 检测 TTY (跟 REPL 一样, 非 TTY 退回无色输出, 让 test 不依赖 ANSI) */
|
|
72
|
+
const isTty = () => Boolean(stdout.isTTY);
|
|
73
|
+
export const THEMES = {
|
|
74
|
+
default: {
|
|
75
|
+
header: ANSI.bold,
|
|
76
|
+
model: ANSI.cyan + ANSI.bold,
|
|
77
|
+
divider: ANSI.dim,
|
|
78
|
+
prompt: ANSI.bold,
|
|
79
|
+
error: ANSI.red,
|
|
80
|
+
success: ANSI.green,
|
|
81
|
+
toolName: ANSI.magenta + ANSI.bold,
|
|
82
|
+
},
|
|
83
|
+
solarized: {
|
|
84
|
+
// 暖冷对比: divider yellow, model blue, prompt bold, error red, success green
|
|
85
|
+
header: ANSI.yellow + ANSI.bold,
|
|
86
|
+
model: ANSI.blue + ANSI.bold,
|
|
87
|
+
divider: ANSI.yellow,
|
|
88
|
+
prompt: ANSI.bold,
|
|
89
|
+
error: ANSI.red,
|
|
90
|
+
success: ANSI.green,
|
|
91
|
+
toolName: ANSI.magenta + ANSI.bold, // solarized 仍 magenta 突出 tool
|
|
92
|
+
},
|
|
93
|
+
monochrome: {
|
|
94
|
+
// 全黑白, 区分靠 dim/bold, 不刺眼
|
|
95
|
+
header: ANSI.bold,
|
|
96
|
+
model: ANSI.bold,
|
|
97
|
+
divider: ANSI.dim,
|
|
98
|
+
prompt: ANSI.bold,
|
|
99
|
+
error: ANSI.dim + ANSI.bold, // monochrome 无红, 仍用 dim+bold 强调错误
|
|
100
|
+
success: ANSI.bold,
|
|
101
|
+
toolName: ANSI.dim + ANSI.bold, // monochrome tool name 用 dim+bold
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const VALID_THEME_NAMES = new Set(['default', 'solarized', 'monochrome']);
|
|
105
|
+
/**
|
|
106
|
+
* 解析 theme 来源 (env > 默认), 找不到或 invalid 时退化到 'default' + stderr warning.
|
|
107
|
+
* 不抛: 启动期不阻塞, 跟 env-gate 风格一致.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveTuiTheme(themeArg) {
|
|
110
|
+
const fromArg = themeArg ?? process.env.DEEPWHALE_TUI_THEME;
|
|
111
|
+
if (fromArg === undefined || fromArg === '')
|
|
112
|
+
return 'default';
|
|
113
|
+
if (VALID_THEME_NAMES.has(fromArg))
|
|
114
|
+
return fromArg;
|
|
115
|
+
// invalid: stderr 提醒, 退化
|
|
116
|
+
stderr.write(`warning: unknown TUI theme '${fromArg}', falling back to 'default' (valid: ${[...VALID_THEME_NAMES].join(', ')})\n`);
|
|
117
|
+
return 'default';
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 染色 wrapper (D-23.1 改签名) — 用 role 查当前 theme.
|
|
121
|
+
* 非 TTY 时退化到原文, 让 CI/管道 log 不带 ANSI.
|
|
122
|
+
*/
|
|
123
|
+
function colorize(text, role, theme = THEMES.default) {
|
|
124
|
+
if (!isTty())
|
|
125
|
+
return text;
|
|
126
|
+
return `${theme[role]}${text}${ANSI.reset}`;
|
|
127
|
+
}
|
|
128
|
+
// ---- 视觉元素 (D-21.2 轻量升级, 2026-06-06) ----
|
|
129
|
+
// 复用红线 (D-20.3 P0-B): 不装新依赖 (无 Ink), 仍用 readline + ANSI.
|
|
130
|
+
// 新增仅 2 个 helper, 替换 header 1 处 + status bar 1 处. 不动 prompt / onChunk /
|
|
131
|
+
// confirm / session 路径.
|
|
132
|
+
/**
|
|
133
|
+
* 画一条横线分隔符, 宽度按 `width` 截 (默认终端列宽, fallback 80).
|
|
134
|
+
* 配色 dim + cyan 拼接, 非 TTY 退化到 `─` 重复, 让 CI/管道 log 也可读.
|
|
135
|
+
*/
|
|
136
|
+
function horizontalRule(width, theme = THEMES.default) {
|
|
137
|
+
const cols = width ?? (stdout.columns && stdout.columns > 20 ? stdout.columns : 80);
|
|
138
|
+
const w = Math.max(20, Math.min(cols - 4, 100));
|
|
139
|
+
const line = '─'.repeat(w);
|
|
140
|
+
return colorize(' ' + line, 'divider', theme);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 格式化状态栏 — D-21.2 升级:
|
|
144
|
+
* - 复用 formatUsageStatus 的 4 字段 (model / in / cached / out / cost)
|
|
145
|
+
* - 加分隔线 + 颜色 (key: cyan, value: 黄色 token, 灰色 cost)
|
|
146
|
+
* - 改成 1 行, 终端窄时(< 60 列) 截断不溢出
|
|
147
|
+
* - 非 TTY 退化到纯文本 (跟现状一样, 不破坏 test)
|
|
148
|
+
*
|
|
149
|
+
* @param usage - formatUsageStatus 返回的原始行, 已是 `tokens X · cached Y · out Z · cost $W` 形态
|
|
150
|
+
* @param model - 模型名, 走 formatUsageStatus 已含, 这里再拼前面 banner 用 cyan 加粗
|
|
151
|
+
*/
|
|
152
|
+
function formatTuiStatusBar(usage, model, theme = THEMES.default) {
|
|
153
|
+
if (usage === null) {
|
|
154
|
+
return colorize(` ${model} · (no usage)`, 'divider', theme);
|
|
155
|
+
}
|
|
156
|
+
// formatUsageStatus 输出形如 "tokens 1.2k · cached 80% · out 200 · cost $0.0012"
|
|
157
|
+
// 我们把 model 提到前面 + 加色 + 末尾补分隔线
|
|
158
|
+
const bar = `${model} · ${usage}`;
|
|
159
|
+
// 终端窄时: 简单截断, 不做折行 (readline prompt 单行假设)
|
|
160
|
+
const cols = stdout.columns && stdout.columns > 20 ? stdout.columns : 80;
|
|
161
|
+
const max = Math.max(40, cols - 4);
|
|
162
|
+
const text = bar.length > max ? bar.slice(0, max - 1) + '…' : bar;
|
|
163
|
+
// 颜色: model 走 model role (theme 决定), usage 走 divider (跟 horizontalRule 一致)
|
|
164
|
+
return colorize(' ' + text, 'divider', theme);
|
|
165
|
+
}
|
|
166
|
+
// ---- D-23.2 语法高亮 (2026-06-06) ----
|
|
167
|
+
//
|
|
168
|
+
// 用户拍板 B: onChunk 来的 assistant stream 染色 (不染 readline input, 染 cursor 会乱).
|
|
169
|
+
// 4 类:
|
|
170
|
+
// 1. 工具名 (BashTool / ReadFileTool / WriteFileTool / EditTool / GlobTool / GrepTool) → toolName role
|
|
171
|
+
// 2. 数字 (整数 / 小数 / 百分号 / 倍数 / 货币) → success role
|
|
172
|
+
// 3. 文件路径 (./rel / /abs / node_modules/...) → model role
|
|
173
|
+
// 4. 其它 → 原样 (不染)
|
|
174
|
+
//
|
|
175
|
+
// 算法: 一次正则扫, 用 split-keep-separator 形式拼回原顺序. 非 TTY 退化到原 text (跟 colorize 一致).
|
|
176
|
+
//
|
|
177
|
+
// 风险: chunk content 是一次 fragment (e.g. "Hello, I "), 正则 match 走贪婪, 实际 chunk 长 50 chars 量级 OK.
|
|
178
|
+
// 跟 Fish / Starship prompt 染色同形态. 不动 readline input 路径.
|
|
179
|
+
/** 工具名白名单 (跟 registry.createDefaultRegistry 同步, 加新 tool 必加这里) */
|
|
180
|
+
const TOOL_NAME_RE = /\b(BashTool|ReadFileTool|WriteFileTool|EditTool|GlobTool|GrepTool|ListDirectoryTool|FileReadTool|FileWriteTool)\b/g;
|
|
181
|
+
/** 数字 (整数 / 小数 / 百分号 / 倍数 / 货币 / + 后缀 e.g. 100+) */
|
|
182
|
+
const NUMBER_RE = /(\$?\d+(?:\.\d+)?(?:\+|%|x|ms|s|kb|mb|k|m|gb)?)/g;
|
|
183
|
+
/** 文件路径 (./rel, /abs, 含 /) */
|
|
184
|
+
const PATH_RE = /((?:\.{1,2}\/|\/)[^\s,;:'"`]+)/g;
|
|
185
|
+
/**
|
|
186
|
+
* 语法高亮 chunk content. 非 TTY 退化到原 text (跟 colorize 行为一致, 跟 CI/管道 log 兼容).
|
|
187
|
+
*
|
|
188
|
+
* @param text - 单 chunk 文本 fragment
|
|
189
|
+
* @param theme - 当前 theme (从 runTuiMode 闭包传入)
|
|
190
|
+
* @param forceColor - D-23.2 测试 hook: 强制当 TTY (非默认) 用于 unit test 验证染色字节.
|
|
191
|
+
* 生产路径 (runTuiMode) 不传, 走 isTty() 自动判断.
|
|
192
|
+
* @returns 染色后文本 (含 ANSI escape) 或原 text (非 TTY)
|
|
193
|
+
*/
|
|
194
|
+
export function highlightChunk(text, theme = THEMES.default, forceColor) {
|
|
195
|
+
if (!forceColor && !isTty())
|
|
196
|
+
return text;
|
|
197
|
+
if (text.length === 0)
|
|
198
|
+
return text;
|
|
199
|
+
// 用 1 个 split-keep 数组, 按出现顺序拼
|
|
200
|
+
// 简化: 用单个正则 union, 优先匹配顺序是 TOOL_NAME > PATH > NUMBER (用 capturing group).
|
|
201
|
+
// 但 4 类 union 互相覆盖 (path 含数字, 工具名可能跟 path 相邻), 改用"先标记后还原"算法:
|
|
202
|
+
// 1. 扫一遍, 收集 [start, end, role] 区间 (按优先级 tool > path > number)
|
|
203
|
+
// 2. 按顺序拼 (区间内用 theme 染色, 区间外原文)
|
|
204
|
+
// 这样避免 union 正则优先级错乱.
|
|
205
|
+
const ranges = [];
|
|
206
|
+
// 1) tool name (优先级最高, 因为是 word boundary, 不跟 path 重叠)
|
|
207
|
+
for (const m of text.matchAll(TOOL_NAME_RE)) {
|
|
208
|
+
if (m.index === undefined)
|
|
209
|
+
continue;
|
|
210
|
+
ranges.push({ start: m.index, end: m.index + m[0].length, role: 'toolName' });
|
|
211
|
+
}
|
|
212
|
+
// 2) path (跳过已被 tool 覆盖的区间)
|
|
213
|
+
for (const m of text.matchAll(PATH_RE)) {
|
|
214
|
+
if (m.index === undefined)
|
|
215
|
+
continue;
|
|
216
|
+
const start = m.index;
|
|
217
|
+
const end = m.index + m[0].length;
|
|
218
|
+
if (ranges.some((r) => start >= r.start && end <= r.end))
|
|
219
|
+
continue; // 在 tool 内, 跳过
|
|
220
|
+
ranges.push({ start, end, role: 'model' });
|
|
221
|
+
}
|
|
222
|
+
// 3) number (跳过已被 tool/path 覆盖)
|
|
223
|
+
for (const m of text.matchAll(NUMBER_RE)) {
|
|
224
|
+
if (m.index === undefined)
|
|
225
|
+
continue;
|
|
226
|
+
const start = m.index;
|
|
227
|
+
const end = m.index + m[0].length;
|
|
228
|
+
if (ranges.some((r) => start >= r.start && end <= r.end))
|
|
229
|
+
continue;
|
|
230
|
+
ranges.push({ start, end, role: 'success' });
|
|
231
|
+
}
|
|
232
|
+
if (ranges.length === 0)
|
|
233
|
+
return text;
|
|
234
|
+
// 按 start 排序, 拼回
|
|
235
|
+
ranges.sort((a, b) => a.start - b.start);
|
|
236
|
+
const parts = [];
|
|
237
|
+
let cursor = 0;
|
|
238
|
+
for (const r of ranges) {
|
|
239
|
+
if (r.start > cursor)
|
|
240
|
+
parts.push(text.slice(cursor, r.start));
|
|
241
|
+
parts.push(colorize(text.slice(r.start, r.end), r.role, theme));
|
|
242
|
+
cursor = r.end;
|
|
243
|
+
}
|
|
244
|
+
if (cursor < text.length)
|
|
245
|
+
parts.push(text.slice(cursor));
|
|
246
|
+
return parts.join('');
|
|
247
|
+
}
|
|
248
|
+
// ---- D-22.1 命令历史持久化 (2026-06-06) ----
|
|
249
|
+
//
|
|
250
|
+
// 复用红线 (D-20.3 P0-B): TUI-only feature, 不动 REPL/print mode.
|
|
251
|
+
// 复用红线 (D-19 复用 confirm controller): 历史加载在 runTuiMode 启动期, confirm 期间不动 history.
|
|
252
|
+
//
|
|
253
|
+
// 存储: ~/.deepwhale/tui-history (JSONL, 每行 1 条 raw prompt, 0o600 权限, max 1000 条 LRU).
|
|
254
|
+
// readline history 数组语义: 最新一条在尾部 (所以 load 时反序, append 时 push 后写尾).
|
|
255
|
+
//
|
|
256
|
+
// D-25 (2026-06-06) 抽 tuiHistoryPath/Load/Append/Truncate 到 coding-agent util
|
|
257
|
+
// (跟 tui-ink 1:1 共享). 详见 ../util/tui-history.ts.
|
|
258
|
+
// ---- D-22.2 流式 token spinner (2026-06-06) ----
|
|
259
|
+
//
|
|
260
|
+
// 5 帧: ⠋ ⠙ ⠹ ⠸ ⠼, 80ms / 帧. 走 \r carriage return + \x1b[K clear-to-end-of-line.
|
|
261
|
+
// Windows 兼容: cursorTo(0) + clearLine(1) 兜底 (Win10/11 终端对 \r 也支持).
|
|
262
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼'];
|
|
263
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
264
|
+
const SPINNER_LABEL = 'thinking…';
|
|
265
|
+
class Spinner {
|
|
266
|
+
timer = null;
|
|
267
|
+
frame = 0;
|
|
268
|
+
active = false;
|
|
269
|
+
start(stream = stdout) {
|
|
270
|
+
if (this.active)
|
|
271
|
+
return;
|
|
272
|
+
if (!isTty())
|
|
273
|
+
return; // 非 TTY 不转 (跟 colorize 一致, 避免管道/重定向被转)
|
|
274
|
+
this.active = true;
|
|
275
|
+
this.frame = 0;
|
|
276
|
+
this.render(stream);
|
|
277
|
+
this.timer = setInterval(() => {
|
|
278
|
+
this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
|
|
279
|
+
this.render(stream);
|
|
280
|
+
}, SPINNER_INTERVAL_MS);
|
|
281
|
+
}
|
|
282
|
+
stop(stream = stdout) {
|
|
283
|
+
if (!this.active)
|
|
284
|
+
return;
|
|
285
|
+
this.active = false;
|
|
286
|
+
if (this.timer) {
|
|
287
|
+
clearInterval(this.timer);
|
|
288
|
+
this.timer = null;
|
|
289
|
+
}
|
|
290
|
+
// 清除当前行 (覆盖 spinner 字符)
|
|
291
|
+
// Windows 兼容: cursorTo(0) + clearLine(1) 是 TTY 行为, stdout 在 isTTY 时有这俩方法.
|
|
292
|
+
// 非 TTY 走空格 + \r 兜底.
|
|
293
|
+
const ttyStream = stream;
|
|
294
|
+
if (typeof ttyStream.cursorTo === 'function' && typeof ttyStream.clearLine === 'function') {
|
|
295
|
+
try {
|
|
296
|
+
ttyStream.cursorTo(0);
|
|
297
|
+
ttyStream.clearLine(1);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
stream.write('\r' + ' '.repeat(SPINNER_LABEL.length + 4) + '\r');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
stream.write('\r' + ' '.repeat(SPINNER_LABEL.length + 4) + '\r');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
render(stream) {
|
|
308
|
+
const text = `${SPINNER_FRAMES[this.frame]} ${SPINNER_LABEL}`;
|
|
309
|
+
// \r 回行首, [K 清到行尾
|
|
310
|
+
stream.write(`\r${text}\x1b[K`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// 暴露 Spinner 类供测试用 (D-22.2 verification)
|
|
314
|
+
export { Spinner, SPINNER_FRAMES, SPINNER_INTERVAL_MS, SPINNER_LABEL };
|
|
315
|
+
// ---- D-22.3 Multi-line input (2026-06-06) ----
|
|
316
|
+
//
|
|
317
|
+
// Hermes 风格: `\` 续行 + `\\` 转义. readline 维持 terminal: true 拿 ↑↓ history.
|
|
318
|
+
// 续行机制: 维护 `multiLineBuffer` 状态, 收到 `\` 结尾行不喂给 turn, 下次行追加.
|
|
319
|
+
// 全局单例 (TUI 单 stream), 在 runTuiMode 闭包内状态化.
|
|
320
|
+
function isContinuationLine(line) {
|
|
321
|
+
return line.endsWith('\\') && !line.endsWith('\\\\');
|
|
322
|
+
}
|
|
323
|
+
function joinContinuation(lines) {
|
|
324
|
+
// ["line1\\", "line2\\", "line3"] → "line1\nline2\nline3"
|
|
325
|
+
return lines.map((l) => l.replace(/\\$/, '')).join('\n');
|
|
326
|
+
}
|
|
327
|
+
// ---- TUI 主入口 ----
|
|
328
|
+
export async function runTuiMode(options = {}) {
|
|
329
|
+
const out = options.output ?? stdout;
|
|
330
|
+
const err = options.errorOutput ?? stderr;
|
|
331
|
+
const enableToolLoop = options.enableToolLoop ?? true;
|
|
332
|
+
const sessionPath = options.sessionPath;
|
|
333
|
+
// D-23.1 (2026-06-06): 解析 theme. options.theme 优先 > DEEPWHALE_TUI_THEME env > 'default'.
|
|
334
|
+
// 解析里含 invalid → stderr warning + 退化, 不抛 (跟 env-gate 风格一致).
|
|
335
|
+
const themeName = resolveTuiTheme(options.theme);
|
|
336
|
+
const theme = THEMES[themeName];
|
|
337
|
+
// sandbox env 解析 (跟 print mode / REPL 一致)
|
|
338
|
+
const sandboxRunner = resolveSandboxRunnerFromEnv({ sandboxRoot: process.cwd() });
|
|
339
|
+
const policyYes = options.yes ?? false;
|
|
340
|
+
// lazy client (跟 REPL D-11-4 拍板一致: 无 key 不阻塞启动, 首次 chat 才报错)
|
|
341
|
+
const clientFromOptions = options.client;
|
|
342
|
+
let client = clientFromOptions ?? null;
|
|
343
|
+
let clientError = null;
|
|
344
|
+
const tryCreateClient = () => {
|
|
345
|
+
if (clientFromOptions)
|
|
346
|
+
return { client: clientFromOptions, error: null };
|
|
347
|
+
if (client !== null || clientError !== null) {
|
|
348
|
+
return { client, error: clientError };
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
const c = createDefaultClient({
|
|
352
|
+
...(options.provider !== undefined ? { provider: options.provider } : {}),
|
|
353
|
+
...(options.model !== undefined ? { model: options.model } : {}),
|
|
354
|
+
});
|
|
355
|
+
client = c;
|
|
356
|
+
clientError = null;
|
|
357
|
+
return { client: c, error: null };
|
|
358
|
+
}
|
|
359
|
+
catch (e) {
|
|
360
|
+
const err0 = e instanceof Error ? e : new Error(String(e));
|
|
361
|
+
clientError = err0;
|
|
362
|
+
return { client: null, error: err0 };
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
// D-19 复用 confirm controller
|
|
366
|
+
const confirmController = createReplConfirm({ output: out });
|
|
367
|
+
const tuiPolicy = {
|
|
368
|
+
...staticToolPolicy,
|
|
369
|
+
confirm: confirmController.confirm,
|
|
370
|
+
};
|
|
371
|
+
// session 加载
|
|
372
|
+
let workingMessages = [];
|
|
373
|
+
const writer = sessionPath ? new SessionWriter(sessionPath) : null;
|
|
374
|
+
const reader = sessionPath ? new SessionReader(sessionPath) : null;
|
|
375
|
+
if (writer && reader) {
|
|
376
|
+
try {
|
|
377
|
+
await writer.open();
|
|
378
|
+
const loaded = await loadSession(reader);
|
|
379
|
+
workingMessages = [...loaded.messages];
|
|
380
|
+
if (workingMessages.length > 0) {
|
|
381
|
+
out.write(colorize(` ${loaded.messages.length} messages resumed from session\n`, 'divider', theme) + '\n');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
catch (e) {
|
|
385
|
+
err.write(`warning: could not load session: ${String(e)}\n`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Sprint 1c-revive-4-D-20.3 P0-B (2026-06-05): TUI minimal scope 不接 compaction
|
|
389
|
+
// (跟 minimal 拍板一致: D-20.3 P0 只做启动/输入/stream/confirm/exit/session 闭环).
|
|
390
|
+
// Compaction 是 D-20.3 P2, 留 v1.1. options.compactionConfig 字段保留 (跟 print mode
|
|
391
|
+
// 同接口), 但当前 implementation 不消费. 这样 binary 接口稳定, 后续 sprint 直接接.
|
|
392
|
+
if (options.compactionConfig && writer) {
|
|
393
|
+
// 显式 silently no-op (minimal TUI 暂不接 compaction, 避免跟 P0 范围扩大).
|
|
394
|
+
// 注: print mode 抛 warning (D-6 拍板), TUI 不抛 (minimal scope, 留扩展点).
|
|
395
|
+
}
|
|
396
|
+
// 顶部 header — D-21.2 轻量升级: 横线分隔 + banner
|
|
397
|
+
const initialClientState = tryCreateClient();
|
|
398
|
+
const modelName = initialClientState.client?.model ?? 'not-configured';
|
|
399
|
+
out.write('\n');
|
|
400
|
+
out.write(horizontalRule(undefined, theme) + '\n');
|
|
401
|
+
out.write(colorize(' deepwhale tui ', 'header', theme) +
|
|
402
|
+
colorize(modelName, 'model', theme) +
|
|
403
|
+
colorize(' · type a prompt, /help, /verify, /exit (or q)\n', 'divider', theme));
|
|
404
|
+
out.write(horizontalRule(undefined, theme) + '\n\n');
|
|
405
|
+
if (initialClientState.error) {
|
|
406
|
+
err.write(`warning: API key not set, chat will fail until DEEPSEEK_API_KEY or ANTHROPIC_AUTH_TOKEN is set.\n`);
|
|
407
|
+
}
|
|
408
|
+
// readline (跟 REPL 同形态)
|
|
409
|
+
// D-22 (2026-06-06) 拍板: terminal: true 拿 ↑↓ history (D-22.1) + cursor (multi-line D-22.3).
|
|
410
|
+
// 复用红线: SIGINT 仍走 process.on('SIGINT', onSigint) (D-19 P2-Ctrl+C), readline 不接管
|
|
411
|
+
// (它 terminal mode 默认会按 Ctrl+C 抛 'SIGINT' 事件, 我们的 onSigint 仍 process 级别接收).
|
|
412
|
+
const rl = createInterface({
|
|
413
|
+
input: options.input ?? stdin,
|
|
414
|
+
terminal: true,
|
|
415
|
+
output: out,
|
|
416
|
+
});
|
|
417
|
+
// D-22.1: 加载历史到 readline (从老到新, 跟 readline 内部 history 数组语义一致)
|
|
418
|
+
// 注: Node 18+ readline 实例有 `.history: string[]` 字段, 但官方 .d.ts 没声明,
|
|
419
|
+
// 用 any cast 兜底.
|
|
420
|
+
const rlAny = rl;
|
|
421
|
+
rlAny.history = tuiHistoryLoad();
|
|
422
|
+
return new Promise((resolve) => {
|
|
423
|
+
let exiting = false;
|
|
424
|
+
let turnInFlight = false;
|
|
425
|
+
let pendingExit = false;
|
|
426
|
+
// D-22.3: multi-line input buffer (Hermes 风格 `\ 续行 + \\ 转义)
|
|
427
|
+
const multiLineBuffer = [];
|
|
428
|
+
// D-22.2: 流式 token spinner (assistant stream 期间)
|
|
429
|
+
const spinner = new Spinner();
|
|
430
|
+
const finish = async (code) => {
|
|
431
|
+
if (exiting)
|
|
432
|
+
return;
|
|
433
|
+
exiting = true;
|
|
434
|
+
process.off('SIGINT', onSigint);
|
|
435
|
+
// D-22.2: 退出前停 spinner (兜底, 避免动画卡住 shell prompt)
|
|
436
|
+
spinner.stop(out);
|
|
437
|
+
rl.close();
|
|
438
|
+
if (writer) {
|
|
439
|
+
try {
|
|
440
|
+
await writer.close();
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
/* best-effort */
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
out.write('\n' + colorize(' Goodbye!\n', 'divider', theme));
|
|
447
|
+
resolve(code);
|
|
448
|
+
};
|
|
449
|
+
// turn abort controller (D-19 P2-Ctrl+C 拍板)
|
|
450
|
+
let turnAbortController = new AbortController();
|
|
451
|
+
const onSigint = () => {
|
|
452
|
+
if (confirmController.hasPending()) {
|
|
453
|
+
confirmController.dismiss();
|
|
454
|
+
}
|
|
455
|
+
if (!turnAbortController.signal.aborted) {
|
|
456
|
+
turnAbortController.abort();
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
process.on('SIGINT', onSigint);
|
|
460
|
+
const prompt = () => {
|
|
461
|
+
out.write(colorize(' > ', 'prompt', theme));
|
|
462
|
+
};
|
|
463
|
+
prompt();
|
|
464
|
+
rl.on('line', async (rawLine) => {
|
|
465
|
+
// D-22.3 (2026-06-06): Hermes 风格多行输入.
|
|
466
|
+
// - 末尾 `\` 续行 (不喂给 turn, 攒入 multiLineBuffer)
|
|
467
|
+
// - 末尾 `\\` (转义) 不当续行, 当字面 `\` 处理
|
|
468
|
+
// - 空行 + 末尾 `\` → 取消续行, 提交空 prompt
|
|
469
|
+
const isCont = isContinuationLine(rawLine);
|
|
470
|
+
if (isCont) {
|
|
471
|
+
multiLineBuffer.push(rawLine);
|
|
472
|
+
// 续行提示 (跟 shell 类似 `> `)
|
|
473
|
+
out.write(colorize(' … ', 'divider', theme));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
// 收尾 (非续行), 把 buffer 最后一行 + 当前行合并
|
|
477
|
+
const assembled = multiLineBuffer.length > 0
|
|
478
|
+
? joinContinuation([...multiLineBuffer, rawLine])
|
|
479
|
+
: rawLine;
|
|
480
|
+
multiLineBuffer.length = 0;
|
|
481
|
+
const line = assembled.trim();
|
|
482
|
+
// D-19 拍板: confirm 期间 line 优先喂 confirm
|
|
483
|
+
if (confirmController.hasPending()) {
|
|
484
|
+
if (line === 'exit' || line === 'quit' || line === '/exit' || line === '/quit' || line === 'q') {
|
|
485
|
+
confirmController.dismiss();
|
|
486
|
+
pendingExit = true;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const consumed = confirmController.offerLine(line);
|
|
490
|
+
if (consumed)
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
// 内建命令
|
|
494
|
+
if (line === '') {
|
|
495
|
+
prompt();
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (line === 'q' || line === 'exit' || line === 'quit' || line === '/exit' || line === '/quit') {
|
|
499
|
+
if (turnInFlight) {
|
|
500
|
+
pendingExit = true;
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
await finish(0);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (line === '/help') {
|
|
507
|
+
out.write(colorize('\n Commands:\n' +
|
|
508
|
+
' /help show this help\n' +
|
|
509
|
+
' /verify run build/lint/typecheck/test (no LLM needed)\n' +
|
|
510
|
+
' /exit, /quit, q exit TUI\n\n', 'divider', theme));
|
|
511
|
+
prompt();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (line === '/verify') {
|
|
515
|
+
// 跟 REPL /verify 同语义 — 调 runVerify, 写 verification event
|
|
516
|
+
try {
|
|
517
|
+
const { runVerify, formatReport, buildSummaryAndNext } = await import('../verify/index.js');
|
|
518
|
+
const report = await runVerify();
|
|
519
|
+
const filled = buildSummaryAndNext(report);
|
|
520
|
+
const text = formatReport({ ...report, summary: filled.summary, nextSuggestedAction: filled.nextSuggestedAction });
|
|
521
|
+
out.write(text + '\n');
|
|
522
|
+
if (writer) {
|
|
523
|
+
const { appendVerificationEvent } = await import('../agent/index.js');
|
|
524
|
+
const failedCount = report.checks.filter((c) => c.status !== 'passed').length;
|
|
525
|
+
await appendVerificationEvent(writer, {
|
|
526
|
+
status: report.overallStatus,
|
|
527
|
+
durationMs: report.durationMs,
|
|
528
|
+
commandCount: report.checks.length,
|
|
529
|
+
failedCount,
|
|
530
|
+
summary: filled.summary,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
err.write(`error: verify failed: ${e instanceof Error ? e.message : String(e)}\n\n`);
|
|
536
|
+
}
|
|
537
|
+
prompt();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
// 队列守卫 (跟 REPL D-19.5 拍板)
|
|
541
|
+
if (turnInFlight) {
|
|
542
|
+
out.write(colorize(' (turn in flight, please wait)\n', 'divider', theme));
|
|
543
|
+
prompt();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
turnInFlight = true;
|
|
547
|
+
turnAbortController = new AbortController();
|
|
548
|
+
// lazy client
|
|
549
|
+
const c = clientFromOptions ? { client: clientFromOptions, error: null } : tryCreateClient();
|
|
550
|
+
if (c.client === null) {
|
|
551
|
+
err.write(`error: API key not set. set DEEPSEEK_API_KEY or ANTHROPIC_AUTH_TOKEN.\n\n`);
|
|
552
|
+
turnInFlight = false;
|
|
553
|
+
prompt();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const liveClient = c.client;
|
|
557
|
+
try {
|
|
558
|
+
// 持久化 user input
|
|
559
|
+
if (writer) {
|
|
560
|
+
const userEvent = { kind: 'user', ts: Date.now(), content: line };
|
|
561
|
+
await writer.append(userEvent);
|
|
562
|
+
}
|
|
563
|
+
// 构造 turn messages
|
|
564
|
+
const turnMessages = [
|
|
565
|
+
...workingMessages,
|
|
566
|
+
{ role: 'user', content: line },
|
|
567
|
+
];
|
|
568
|
+
out.write('\n'); // user 跟 assistant 间空行
|
|
569
|
+
let result;
|
|
570
|
+
if (enableToolLoop) {
|
|
571
|
+
// D-22.2 (2026-06-06): turn 启动立刻启 spinner, content 来了就停
|
|
572
|
+
spinner.start(out);
|
|
573
|
+
result = await runToolLoop(liveClient, turnMessages, {
|
|
574
|
+
registry: createDefaultRegistry({ sandboxRunner }),
|
|
575
|
+
onChunk: (chunk) => {
|
|
576
|
+
if (chunk.content) {
|
|
577
|
+
spinner.stop(out);
|
|
578
|
+
// D-23.2 (2026-06-06): 语法高亮 assistant stream (tool 名 / 数字 / path 染色)
|
|
579
|
+
out.write(highlightChunk(chunk.content, theme));
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
...(options.maxSteps !== undefined ? { maxSteps: options.maxSteps } : {}),
|
|
583
|
+
policy: tuiPolicy,
|
|
584
|
+
isInteractive: true, // TUI = 交互
|
|
585
|
+
yes: policyYes,
|
|
586
|
+
// Sprint 1c-revive-5-D-20.6.4 review-fix (2026-06-06): 透传 turnAbortController.signal
|
|
587
|
+
// 给 runToolLoop, 让 SIGINT (onSigint) abort 时透传到 LLM stream / tool exec /
|
|
588
|
+
// policy.confirm. 跟 repl.ts:509 对齐. 之前漏传, SIGINT 只 abort controller
|
|
589
|
+
// 不往下传, 工具循环在 tool execution / confirm 等关键点收不到 abort,
|
|
590
|
+
// hang 住, 跟 D-19 Ctrl+C/cleanup 链路不完整.
|
|
591
|
+
signal: turnAbortController.signal,
|
|
592
|
+
...(writer ? { writer } : {}),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
// --no-tool-loop 直发
|
|
597
|
+
// D-22.2 (2026-06-06): spinner 启, content 来了停
|
|
598
|
+
spinner.start(out);
|
|
599
|
+
const streamResult = await liveClient.stream(turnMessages, {
|
|
600
|
+
onChunk: (chunk) => {
|
|
601
|
+
if (chunk.delta.content) {
|
|
602
|
+
spinner.stop(out);
|
|
603
|
+
// D-23.2 (2026-06-06): 语法高亮 --no-tool-loop 直发 stream
|
|
604
|
+
out.write(highlightChunk(chunk.delta.content, theme));
|
|
605
|
+
}
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
result = {
|
|
609
|
+
messages: [...turnMessages, { role: 'assistant', content: streamResult.content }],
|
|
610
|
+
final: streamResult,
|
|
611
|
+
steps: [
|
|
612
|
+
{
|
|
613
|
+
kind: 'assistant',
|
|
614
|
+
ts: Date.now(),
|
|
615
|
+
message: { role: 'assistant', content: streamResult.content },
|
|
616
|
+
result: streamResult,
|
|
617
|
+
},
|
|
618
|
+
],
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
// TUI 格式化: tool call / result (跟 print mode printStepSummary 同形态, 但加 ANSI)
|
|
622
|
+
for (const step of result.steps) {
|
|
623
|
+
if (step.kind === 'tool') {
|
|
624
|
+
const status = step.result.success ? colorize('✓', 'success', theme) : colorize('✗', 'error', theme);
|
|
625
|
+
out.write(`\n ${status} ${colorize(step.tool_call.name, 'toolName', theme)} (${step.duration_ms}ms)\n`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// 持久化
|
|
629
|
+
if (writer) {
|
|
630
|
+
try {
|
|
631
|
+
await persistToolLoopSteps(writer, result.steps);
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
/* best-effort */
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// 更新 working messages (跟 REPL 一致, 加 user + 所有 steps 消息)
|
|
638
|
+
workingMessages = [
|
|
639
|
+
...result.messages,
|
|
640
|
+
];
|
|
641
|
+
// 状态栏 (复用 formatUsageStatus, 4 字段) — D-21.2 轻量升级: 上下加横线分隔
|
|
642
|
+
const usageLine = formatUsageStatus(result.final.usage);
|
|
643
|
+
if (usageLine !== null) {
|
|
644
|
+
out.write('\n' + horizontalRule() + '\n');
|
|
645
|
+
out.write(formatTuiStatusBar(usageLine, modelName) + '\n');
|
|
646
|
+
out.write(horizontalRule() + '\n');
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
out.write('\n' + horizontalRule() + '\n');
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
catch (e) {
|
|
653
|
+
// D-22.2: 异常时停 spinner (兜底)
|
|
654
|
+
spinner.stop(out);
|
|
655
|
+
if (isToolLoopError(e)) {
|
|
656
|
+
err.write(`\nerror: tool loop hit max steps (${e.steps})\n`);
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
err.write(`\nerror: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
finally {
|
|
663
|
+
// D-22.2: turn 完 (正常/异常/abort) 停 spinner
|
|
664
|
+
spinner.stop(out);
|
|
665
|
+
// D-22.1: turn 完 (非空, 非内建命令) append 历史
|
|
666
|
+
// 注意: 续行合并后 assembled 才是真 prompt, 但历史里只存 trimmed 单行
|
|
667
|
+
// (跟 bash history 一致, 多行 prompt 存 \n 不易)
|
|
668
|
+
tuiHistoryAppend(assembled);
|
|
669
|
+
turnInFlight = false;
|
|
670
|
+
if (pendingExit) {
|
|
671
|
+
void finish(0);
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
prompt();
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
//# sourceMappingURL=tui.js.map
|