@ghyper9023/pi-dev-workflow 0.4.1 → 0.4.2
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/.pi-dev-output/pi-grill/answers/answer-mpfe77f1-20260521-1913.md +58 -0
- package/.pi-dev-output/pi-grill/answers/answer-mpfh37wu-20260521-2034.md +13 -0
- package/.pi-dev-output/pi-grill/answers/answer-mpfi5q4c-20260521-2104.md +13 -0
- package/.pi-dev-output/pi-grill/answers/answer-mpfizccb-20260521-2127.md +13 -0
- package/.pi-dev-output/pi-grill/answers/answer-mpfjk78k-20260521-2143.md +13 -0
- package/.pi-dev-output/pi-grill/questions/questions-mpfdz1tz-20260521-1907.json +94 -0
- package/.pi-dev-output/pi-plans/20260521-113000-fix-loopcount-timeout.md +215 -0
- package/.pi-dev-output/pi-plans/20260521-1730-grill-input-wrap-back-fix.md +240 -0
- package/.pi-dev-output/pi-plans/20260521-230000-fix-timeout-display-loopcount-gitdiff.md +253 -0
- package/.pi-dev-output/pi-plans/20260521-230500-esc-double-press-confirm-workflow.md +137 -0
- package/.pi-dev-output/pi-plans/20260521-235000-fix-gitdiff-loopcount.md +258 -0
- package/.pi-dev-output/pi-review/html/20260521-2305-review-workflow-index.html +196 -0
- package/.pi-dev-output/pi-review/md/review-20260520-100000.md +91 -0
- package/.pi-dev-output/pi-review/md/review-20260521-140000.md +191 -0
- package/.pi-dev-output/pi-review/md/review-20260521-190000.md +189 -0
- package/.pi-dev-output/pi-review/md/review-20260521-204500.md +241 -0
- package/.pi-dev-output/pi-review/md/review-20260521-214500.md +270 -0
- package/.pi-dev-output/pi-review/md/review-20260521-215158.md +214 -0
- package/.pi-dev-output/pi-review/md/review-20260521-234500.md +201 -0
- package/.pi-dev-output/pi-review/md/review-20260521-235500.md +422 -0
- package/.pi-dev-output/pi-review/md/review-20260522-000000.md +212 -0
- package/.pi-dev-output/pi-review/md/review-20260522-003000.md +377 -0
- package/.pi-dev-output/pi-review/md/review-20260522-003500.md +296 -0
- package/.pi-dev-output/pi-workflow/checkpoint-20260521-113000-fix-loopcount-timeout.json +402 -0
- package/.pi-dev-output/pi-workflow/checkpoint-20260521-1730-grill-input-wrap-back-fix.json +447 -0
- package/.pi-dev-output/pi-workflow/checkpoint-20260521-230000-fix-timeout-display-loopcount-gitdiff.json +708 -0
- package/.pi-dev-output/pi-workflow/checkpoint-20260521-230500-esc-double-press-confirm-workflow.json +365 -0
- package/.pi-dev-output/pi-workflow/checkpoint-20260521-235000-fix-gitdiff-loopcount.json +395 -0
- package/.pi-dev-output/pi-workflow/checkpoint-archive-mpfhyxc5.json +30 -0
- package/.pi-dev-output/pi-workflow/checkpoint-archive-mpfi2unc.json +49 -0
- package/.pi-dev-output/pi-workflow/checkpoint-archive-mpfi382e.json +59 -0
- package/.pi-dev-output/pi-workflow/checkpoint-archive-mpfi5r22.json +76 -0
- package/extensions/dev-prompts.ts +16 -8
- package/extensions/grill-me-agent.ts +23 -7
- package/extensions/ui-helpers.ts +59 -7
- package/extensions/workflow-engine.ts +80 -32
- package/package.json +1 -1
- package/tests/test-loopcount-timeout-fix.mjs +336 -0
- package/themes/oh-my-pi-titanium.json +90 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Grill/Input 文本换行与左方向键返回修复 — 实施计划
|
|
2
|
+
|
|
3
|
+
## 概述
|
|
4
|
+
|
|
5
|
+
修复 extensions/ 目录中 grill 和 dev-* 命令的三个 UI 问题:
|
|
6
|
+
1. dev-* 命令输入栏和 grill 自定义输入框不会自动换行(单行水平滚动),超长文本被截断
|
|
7
|
+
2. grill 提问回答环节的选项(a/b/c...)文字过长时不换行,内容被截断
|
|
8
|
+
3. grill 提问回答环节的 ← 左方向键返回上一题功能未实现
|
|
9
|
+
|
|
10
|
+
## 根因分析
|
|
11
|
+
|
|
12
|
+
### 问题 1:输入不换行
|
|
13
|
+
`ui-helpers.ts` 中的 `uiInput` 函数使用 pi-tui 的 `Input` 组件。该组件是**单行文本输入**,支持水平滚动但不支持换行:`render()` 只输出一行文本,`handlePaste()` 会移除所有换行符。无法通过配置启用多行模式。
|
|
14
|
+
|
|
15
|
+
### 问题 2:选项不换行
|
|
16
|
+
`grill-me-agent.ts` 中的 `showQuestionTUI` 使用 pi-tui 的 `SelectList` 组件显示选项(a/b/c...)。`SelectList.renderItem()` 使用 `truncateToWidth()` 将过长的 label **截断**而非**换行**。SelectList 不支持多行 item 渲染。
|
|
17
|
+
|
|
18
|
+
### 问题 3:左方向键不生效
|
|
19
|
+
`showQuestionTUI` 的 `handleInput` 将键盘事件直接转发给 `selectList.handleInput(data)`。
|
|
20
|
+
SelectList 只处理 `tui.select.up/down/confirm/cancel`(分别绑定了 ↑↓ Enter Esc),**不处理 `left` 键**(left 绑定的是 `tui.editor.cursorLeft`)。因此左方向键事件被静默丢弃。
|
|
21
|
+
|
|
22
|
+
## 文件清单
|
|
23
|
+
|
|
24
|
+
### 修改文件
|
|
25
|
+
| 文件路径 | 改动描述 | 风险等级 |
|
|
26
|
+
|---------|---------|---------|
|
|
27
|
+
| `extensions/grill-me-agent.ts` | 1. 在 `showQuestionTUI.handleInput` 中拦截左方向键实现返回 2. 对过长选项使用 `description` 字段显示完整文本 3. 新增 import | 低 |
|
|
28
|
+
| `extensions/ui-helpers.ts` | 1. `uiInput` 中添加实时换行预览区域 2. `uiInput.handleInput` 中拦截左方向键返回 | 低 |
|
|
29
|
+
|
|
30
|
+
**不修改**:`@earendil-works/pi-tui` 库(SelectList、Input 组件保持不变)。
|
|
31
|
+
|
|
32
|
+
## 实施步骤
|
|
33
|
+
|
|
34
|
+
### 步骤 1:修复 `grill-me-agent.ts` — 左方向键返回 + 选项过长处理
|
|
35
|
+
|
|
36
|
+
- **改动文件**:`extensions/grill-me-agent.ts`
|
|
37
|
+
- **改动内容**:
|
|
38
|
+
|
|
39
|
+
**a) 新增 import(文件头部,约第 13~15 行)**
|
|
40
|
+
添加:
|
|
41
|
+
```typescript
|
|
42
|
+
import { matchesKey, Key, truncateToWidth } from "@earendil-works/pi-tui";
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**b) `showQuestionTUI` 中拦截左方向键(约第 670 行的 `handleInput`)**
|
|
46
|
+
当前代码仅做 `selectList.handleInput(data)`。左方向键传入 SelectList 后无效果。
|
|
47
|
+
|
|
48
|
+
修改为:先检查左方向键,若匹配则直接 `done("__BACK__")`,**不转发给 SelectList**:
|
|
49
|
+
```typescript
|
|
50
|
+
return {
|
|
51
|
+
render: (w) => container.render(w),
|
|
52
|
+
invalidate: () => container.invalidate(),
|
|
53
|
+
handleInput: (data) => {
|
|
54
|
+
// 左方向键 → 返回上一题(SelectList 不处理 left 键,需自行拦截)
|
|
55
|
+
if (backable && currentIndex > 1 && matchesKey(data, Key.left)) {
|
|
56
|
+
done("__BACK__");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
selectList.handleInput(data);
|
|
60
|
+
tui.requestRender();
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**c) 选项文字过长处理(约第 640 行构建 `selectItems` 处)**
|
|
66
|
+
SelectList 的 `renderItem` 对主列使用 `truncateToWidth` 截断。当选项文本超过主列宽度时,内容不可见。
|
|
67
|
+
|
|
68
|
+
修改方案:将完整 label **截断到合理长度**,将完整文本放入 `description` 字段。SelectList 在有 description 时会使用两列布局(主列 + description 列),让用户尽可能看到更多内容:
|
|
69
|
+
```typescript
|
|
70
|
+
const MAX_OPTION_LABEL = 50;
|
|
71
|
+
const selectItems: SelectItem[] = q.options.map((opt, i) => {
|
|
72
|
+
const prefix = `(${String.fromCharCode(97 + i)}) `;
|
|
73
|
+
const label = opt === previousAnswer
|
|
74
|
+
? `${prefix}${opt} - 上次选择`
|
|
75
|
+
: `${prefix}${opt}`;
|
|
76
|
+
const truncated = truncateToWidth(label, MAX_OPTION_LABEL, "...");
|
|
77
|
+
return {
|
|
78
|
+
value: `opt-${i}`,
|
|
79
|
+
label: truncated,
|
|
80
|
+
// 只有被截断时才提供 description,展示完整文本
|
|
81
|
+
description: truncated !== label ? opt : undefined,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**注意**:`truncateToWidth` 已经是 `@earendil-works/pi-tui` 的导出,可直接 import。
|
|
87
|
+
|
|
88
|
+
- **验证方式**:
|
|
89
|
+
- 运行 `/dev-fix` → 进入 grill → 在第一个问题上按 ← 键(此时 backable=false,currentIndex=1),不应触发返回
|
|
90
|
+
- 进入第二个问题,按 ← 键,确认返回第一个问题
|
|
91
|
+
- 确认选择了第一个问题的答案后,返回第一个问题,应看到" - 上次选择"标记
|
|
92
|
+
- 对包含长选项文字的问题,确认 label 被截断但 description 列显示完整文本
|
|
93
|
+
|
|
94
|
+
### 步骤 2:修复 `ui-helpers.ts` — 输入框实时换行预览
|
|
95
|
+
|
|
96
|
+
- **改动文件**:`extensions/ui-helpers.ts`
|
|
97
|
+
- **改动内容**:
|
|
98
|
+
|
|
99
|
+
在 `uiInput` 函数的返回对象中(约第 230~245 行),当前的 `handleInput` 仅转发给 `input.handleInput(data)`。修改为:在转发后读取 `input.getValue()`,用 `wrapTextWithAnsi` 换行后更新一个预览 Text 组件。
|
|
100
|
+
|
|
101
|
+
完整修改如下(仅修改 `uiInput` 函数的返回部分):
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
// 在 Input 上方添加预览 Text(约第 215 行,在 input 创建前)
|
|
105
|
+
const previewText = new Text("", 0, 0);
|
|
106
|
+
container.addChild(previewText);
|
|
107
|
+
container.addChild(new Spacer(1));
|
|
108
|
+
// ... 创建 input ...
|
|
109
|
+
|
|
110
|
+
// 修改 return 对象中的 handleInput(约第 230 行)
|
|
111
|
+
return {
|
|
112
|
+
render: (w) => container.render(w),
|
|
113
|
+
invalidate: () => container.invalidate(),
|
|
114
|
+
handleInput: (data) => {
|
|
115
|
+
// 先让 Input 处理输入(更新内部 value)
|
|
116
|
+
input.handleInput(data);
|
|
117
|
+
|
|
118
|
+
// 读取更新后的 value,更新预览
|
|
119
|
+
const val = input.getValue();
|
|
120
|
+
if (val.length > 0) {
|
|
121
|
+
const wrapped = wrapTextWithAnsi(val, width - 4);
|
|
122
|
+
const previewContent = wrapped
|
|
123
|
+
.map(l => theme.fg("dim", ` ${l}`))
|
|
124
|
+
.join("\n");
|
|
125
|
+
previewText.setText(previewContent);
|
|
126
|
+
} else {
|
|
127
|
+
previewText.setText("");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
tui.requestRender();
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**注意**:
|
|
136
|
+
- `previewText` 的初始状态应为空文本(`""`)
|
|
137
|
+
- 每次按键后,`input.handleInput(data)` 会修改 Input 的内部 value,然后我们立即读取并更新预览
|
|
138
|
+
- 使用 `theme.fg("dim", ...)` 使预览文本颜色较淡,与输入框区分
|
|
139
|
+
- `wrapTextWithAnsi` 已经 import
|
|
140
|
+
|
|
141
|
+
- **验证方式**:
|
|
142
|
+
- 运行 `/dev-feat` → 在"核心功能描述"输入框输入超过终端宽度的长文本 → 确认上方出现换行预览
|
|
143
|
+
- 按 Backspace 删除字符 → 确认预览实时更新
|
|
144
|
+
- 按 Esc 取消 → 确认预览消失、输入被取消
|
|
145
|
+
- 按 Enter 提交 → 确认正常提交
|
|
146
|
+
|
|
147
|
+
### 步骤 3:修复 `ui-helpers.ts` — 左方向键返回(自定义输入环节)
|
|
148
|
+
|
|
149
|
+
- **改动文件**:`extensions/ui-helpers.ts`
|
|
150
|
+
- **改动内容**:
|
|
151
|
+
|
|
152
|
+
在 `uiInput` 的 `handleInput` 中,**在调用 `input.handleInput(data)` 之前**拦截左方向键。当 `backable=true` 且按下左方向键时,返回 `BACK_MARKER`。
|
|
153
|
+
|
|
154
|
+
现有代码中已经支持 `Ctrl+Shift+←` 作为返回键(约第 240~244 行)。现在增加裸的 ← 键支持:
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
handleInput: (data) => {
|
|
158
|
+
// [新增] 左方向键 → 返回(优先于 Input 的光标左移)
|
|
159
|
+
if (backable && matchesKey(data, Key.left)) {
|
|
160
|
+
done(BACK_MARKER);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// [原有] Ctrl+Shift+← → 返回
|
|
165
|
+
if (backable && matchesKey(data, Key.ctrlShift("left"))) {
|
|
166
|
+
done(BACK_MARKER);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// [原有] Ctrl+Shift+→ → 提交并继续
|
|
170
|
+
if (backable && matchesKey(data, Key.ctrlShift("right"))) {
|
|
171
|
+
done(input.getValue() || "");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// [原有] 转发给 Input 处理
|
|
176
|
+
input.handleInput(data);
|
|
177
|
+
|
|
178
|
+
// [新增] 更新预览(步骤 2)
|
|
179
|
+
const val = input.getValue();
|
|
180
|
+
if (val.length > 0) {
|
|
181
|
+
const wrapped = wrapTextWithAnsi(val, width - 4);
|
|
182
|
+
previewText.setText(
|
|
183
|
+
wrapped.map(l => theme.fg("dim", ` ${l}`)).join("\n")
|
|
184
|
+
);
|
|
185
|
+
} else {
|
|
186
|
+
previewText.setText("");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
tui.requestRender();
|
|
190
|
+
},
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**关于光标左移的替代方案**:拦截左方向键后,Input 组件中失去光标左移能力。但 pi-tui 的 keybinding 中 `tui.editor.cursorLeft` 同时绑定了 `left` 和 `ctrl+b`(见 keybindings.d.ts),用户可以使用 `Ctrl+B` 替代左方向键进行光标左移。这是合理的权衡。
|
|
194
|
+
|
|
195
|
+
- **验证方式**:
|
|
196
|
+
- 进入 grill → 选择"自定义输入" → 按 ← 键 → 确认返回到选项列表
|
|
197
|
+
- 在非 grill 场景的普通输入(如 `/dev-feat` 的第一题)中按 ← 键 → 确认整个 wizard 被取消(因为 backable=true 但当前是第一题,返回即取消)
|
|
198
|
+
- 确认 Ctrl+B 在输入框中仍可左移光标
|
|
199
|
+
|
|
200
|
+
### 步骤 4:更新 hints 文案(可选)
|
|
201
|
+
|
|
202
|
+
- **改动文件**:`extensions/grill-me-agent.ts`(约第 663~667 行)
|
|
203
|
+
- **改动内容**:
|
|
204
|
+
|
|
205
|
+
更新 hint 文本,说明左方向键也可用于返回:
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
const hint = backable && currentIndex > 1
|
|
209
|
+
? " ↑↓ 导航 • Enter 选择 • ← 返回上一题 • Esc 取消全部评审"
|
|
210
|
+
: " ↑↓ 导航 • Enter 选择 • Esc 取消全部评审";
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
- **验证方式**:检查 grill 问题界面底部提示文字是否正确显示
|
|
214
|
+
|
|
215
|
+
## 依赖关系
|
|
216
|
+
|
|
217
|
+
- 步骤 1 与步骤 2、3 无依赖,可并行
|
|
218
|
+
- 步骤 3 依赖步骤 2 的代码修改位置(handleInput 中需同时处理左方向键和预览更新)
|
|
219
|
+
- 步骤 4 可选,在步骤 1 之后
|
|
220
|
+
|
|
221
|
+
## 测试策略
|
|
222
|
+
|
|
223
|
+
手动测试清单:
|
|
224
|
+
|
|
225
|
+
| 测试场景 | 操作 | 预期结果 |
|
|
226
|
+
|---------|------|---------|
|
|
227
|
+
| Grill 左方向键返回 | 在 grill 第二个问题按 ← | 返回第一个问题,保留之前的选择 |
|
|
228
|
+
| Grill 选项显示 | 包含长选项文字的问题 | label 截断有 ...,description 列显示完整文本 |
|
|
229
|
+
| Grill 自定义输入返回 | 选自定义输入后按 ← | 返回到选项列表 |
|
|
230
|
+
| dev-* 长文本预览 | 输入超长文本 | Input 上方显示换行后的完整文本预览 |
|
|
231
|
+
| 普通 Esc 取消 | 按 Esc | 取消当前操作(不破坏原有功能) |
|
|
232
|
+
| 上一步/下一步导航 | 使用 Ctrl+Shift+←/→ | 原有功能不受影响 |
|
|
233
|
+
| 非 backable 左方向键 | 普通输入中按 ← | 光标在输入框中左移 |
|
|
234
|
+
|
|
235
|
+
## 注意事项
|
|
236
|
+
|
|
237
|
+
- **不修改 pi-tui 库代码**,所有改动在 `extensions/` 目录内完成
|
|
238
|
+
- **左方向键拦截**:在 SelectList 环境中,left 键对 SelectList 无意义(SelectList 只用 ↑↓ 导航),拦截 100% 安全。在 Input 环境中,左方向键的"光标左移"功能可用 Ctrl+B 替代
|
|
239
|
+
- **预览性能**:每次按键触发 `wrapTextWithAnsi`,对数百字符的输入性能可忽略
|
|
240
|
+
- **description 列**:SelectList 的 description 列本身也被 `truncateToWidth` 截断,但两列布局比单列能显示更多内容
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# 修复超时时间显示位置、循环次数计数与 git diff 解析 — 实施计划
|
|
2
|
+
|
|
3
|
+
## 概述
|
|
4
|
+
|
|
5
|
+
修复三个独立问题:
|
|
6
|
+
|
|
7
|
+
1. **超时时间显示位置错误**:当前超时时间(如 `超时时间60m`)被显示在步骤主行(step 行),而非子代理行(sub-step 行)。预期行为是 `|__ worker · (52.6s/超时时间60m )`。
|
|
8
|
+
2. **循环次数(loopCount)计数不显示**:上次 commit (01413c9) 修改了 loopCount 显示逻辑,导致 loop-group 启动后 `第 1 次循环` 不再显示。原因为 commit 中 `buildWidgetLines` 删除了 `isRunning` 状态下显示循环次数的逻辑,但 `executeLoopGroup` 中的 `updateWidgetStep` 调用在 loopCount 更新后未在 widget UI 上正确体现。
|
|
9
|
+
3. **getGitDiffChanges 中 git diff 解析使用正则而非简单 string 拆分**:当前正则 `^([MAD])\s+(.+)$` 在解析 `git diff --name-status` 的输出(格式为 `M\t.gitignore` 或 `M .gitignore`)时,可能因不匹配制表符而丢失变更。实际输出非常规整,应使用简单的字符串拆分。
|
|
10
|
+
|
|
11
|
+
## 文件清单
|
|
12
|
+
|
|
13
|
+
### 修改文件
|
|
14
|
+
|
|
15
|
+
| 文件路径 | 改动描述 | 风险等级 |
|
|
16
|
+
|---------|---------|---------|
|
|
17
|
+
| `extensions/ui-helpers.ts` | 修改 `buildWidgetLines` 中的子代理行渲染,在 `status` 图标和 agent 名称后添加 `(当前计时/超时时间Xm)`;恢复步骤行中 `isRunning` 状态下 `loopCount` 的显示逻辑 | 低 |
|
|
18
|
+
| `extensions/workflow-engine.ts` | 修改 `getGitDiffChanges` 中 `git diff --name-status` 和 `git status --porcelain` 的解析逻辑,用简单字符串拆分替代正则匹配 | 低 |
|
|
19
|
+
|
|
20
|
+
## 实施步骤
|
|
21
|
+
|
|
22
|
+
### 步骤 1:修复子代理行的超时时间显示位置(ui-helpers.ts)
|
|
23
|
+
|
|
24
|
+
- **前置条件**:无
|
|
25
|
+
- **改动文件**:`extensions/ui-helpers.ts`
|
|
26
|
+
- **改动内容**:
|
|
27
|
+
|
|
28
|
+
**1a. 在 `buildWidgetLines` 的子代理渲染循环中,在 agent 行后添加计时和超时信息。**
|
|
29
|
+
|
|
30
|
+
当前子代理行渲染(ui-helpers.ts ~第539行):
|
|
31
|
+
```
|
|
32
|
+
lines.push(`${agentIndent}${agentConnector} ${subIcon} ${sub.agent} ·`);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
需要修改为:
|
|
36
|
+
```
|
|
37
|
+
let subDurStr = "";
|
|
38
|
+
let subTimeoutStr = "";
|
|
39
|
+
// 计算 sub-step 的当前时长
|
|
40
|
+
if (sub.startedAt) {
|
|
41
|
+
const elapsedMs = Date.now() - sub.startedAt;
|
|
42
|
+
subDurStr = dim(theme, ` (${formatDurationFull(elapsedMs)}`);
|
|
43
|
+
} else if (isSubRunning) {
|
|
44
|
+
subDurStr = dim(theme, ` (0s`);
|
|
45
|
+
}
|
|
46
|
+
// 从 sub.detail 提取超时信息(detail 已在 runAgentWithProgress 中设置为 "超时时间60m")
|
|
47
|
+
if (sub.detail) {
|
|
48
|
+
subTimeoutStr = dim(theme, `/${sub.detail}`);
|
|
49
|
+
}
|
|
50
|
+
const subDurClose = subDurStr ? dim(theme, ")") : "";
|
|
51
|
+
|
|
52
|
+
lines.push(`${agentIndent}${agentConnector} ${subIcon} ${sub.agent} ·${subDurStr}${subTimeoutStr}${subDurClose}`);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**1b. 步骤行中的超时时间移除/调整**
|
|
56
|
+
|
|
57
|
+
当前步骤行(step 行)有 `durStr + timeout + durClose`,对于 loop-group 步骤,`timeoutMs` 在 workflow-engine.ts 中已被设置为 `undefined`(`step.type === "loop-group" ? undefined : step.timeoutMs`),所以步骤行不会显示超时——这部分逻辑保持不变。
|
|
58
|
+
|
|
59
|
+
但对于非 loop-group 步骤,步骤行仍显示 `(1m59s/超时时间15m)`,这是正确的行为,无需改动。
|
|
60
|
+
|
|
61
|
+
**关键设计决策**:让子代理行显示 `(当前计时/超时时间)`,步骤行对于 loop-group 不显示超时(因为 loop-group 的超时已在子代理级别体现)。
|
|
62
|
+
|
|
63
|
+
- **验证方式**:运行 `node tests/test-loopcount-timeout-fix.mjs`,确认现有测试全部通过;手动检查 widget 渲染逻辑。
|
|
64
|
+
|
|
65
|
+
### 步骤 2:修复循环次数(loopCount)显示(ui-helpers.ts)
|
|
66
|
+
|
|
67
|
+
- **前置条件**:步骤 1 完成
|
|
68
|
+
- **改动文件**:`extensions/ui-helpers.ts`
|
|
69
|
+
- **改动内容**:
|
|
70
|
+
|
|
71
|
+
**2a. 在 `buildWidgetLines` 中,恢复 `isRunning` 状态下显示循环次数的逻辑。**
|
|
72
|
+
|
|
73
|
+
当前代码(ui-helpers.ts ~第471行):
|
|
74
|
+
```typescript
|
|
75
|
+
if (s.loopCount != null && s.loopCount > 0) {
|
|
76
|
+
loopStr = dim(theme, ` · 第 ${s.loopCount} 次循环`);
|
|
77
|
+
} else if (s.maxLoops != null) {
|
|
78
|
+
// 仅在 pending 时显示"第 0 次循环"
|
|
79
|
+
// running 状态时 loopCount 应由 executeLoopGroup 通过 updateWidgetStep 更新
|
|
80
|
+
if (isPending) {
|
|
81
|
+
loopStr = dim(theme, ` · 第 0 次循环`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
这里的问题是:当 loop-group step 处于 `isRunning` 状态且 `loopCount` 已通过 `updateWidgetStep` 设置(如 `loopCount=1`)时,`s.loopCount != null && s.loopCount > 0` 条件应匹配,所以 `第 1 次循环` **应该**显示。但实际运行中,`updateWidgetStep` 在 `executeLoopGroup` 中的调用可能没有被正确触发。
|
|
87
|
+
|
|
88
|
+
排查 `executeLoopGroup` 中的代码(workflow-engine.ts ~第1250行):
|
|
89
|
+
```typescript
|
|
90
|
+
loopCount++;
|
|
91
|
+
// 立即更新 UI 显示当前循环次数
|
|
92
|
+
state.loopCount = loopCount;
|
|
93
|
+
updateWidgetStep(stepIndex, step.label, "running", {
|
|
94
|
+
loopCount,
|
|
95
|
+
maxLoops: step.maxLoops,
|
|
96
|
+
startedAt: _widgetSteps[stepIndex]?.startedAt || Date.now(),
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
这里 `updateWidgetStep` 被调用时传入了 `"running"` 状态和 `loopCount`。调用 `updateWidgetStep` 会触发 `refreshWidget()`,然后 `buildWidgetLines` 读到的 `s.loopCount` 应为刚刚设置的值(例如 1),因此 `s.loopCount != null && s.loopCount > 0` 应成立,`第 1 次循环` 应显示。
|
|
101
|
+
|
|
102
|
+
**根因分析**:问题在于 `updateWidgetStep` 的 `extra` 参数展开覆盖了 step widget state 中的 `loopCount`。在 `updateWidgetStep` 函数中:
|
|
103
|
+
```typescript
|
|
104
|
+
_widgetSteps[index] = {
|
|
105
|
+
...existing,
|
|
106
|
+
label,
|
|
107
|
+
status,
|
|
108
|
+
...extra,
|
|
109
|
+
};
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
这里 `extra` 包含 `{ loopCount, maxLoops, startedAt }`,这会将 `loopCount` 设置到 widget step 上。所以 `s.loopCount` 是有的。理论上代码是正确同步的。
|
|
113
|
+
|
|
114
|
+
但实际行为中,`executeLoopGroup` 的 `while` 循环中,**在 `loopCount++` 和 `updateWidgetStep` 执行之前**,reviewer 的 `runAgentWithProgress` 内部调用了 `refreshWidget()`(通过 `setWidgetSubStepStatus`),这意味着在第一次循环中,widget 在 loopCount 更新前被渲染了。但 `updateWidgetStep` 紧随其后就会更新。
|
|
115
|
+
|
|
116
|
+
**真正的 bug**:在 `executeLoopGroup` 中,第一次循环的 `updateWidgetStep` 调用在 `loopCount++`(从 0 变为 1)之后。因此第一次循环完成后,`loopCount=1`,这能在 widget 上显示 `第 1 次循环`。但是,**当进入下一轮循环时**,`setWidgetSubStepStatus(stepIndex, step.loopAgentName!, "pending")` 和 `setWidgetSubStepStatus(stepIndex, step.reviewAgentName!, "pending")` 被调用,这些调用触发 `refreshWidget()` 但**不会**更新 `loopCount`,所以 `loopCount` 仍然是上一次循环的值(如 1),所以在第二次循环的 worker 运行时,UI 显示的还是 `第 1 次循环`。
|
|
117
|
+
|
|
118
|
+
但实际上,根据需求描述:"第 0 次循环只在排队时候显示了,等 loop 组开始工作连第 1 次循环的提示都不见了"。这意味着 **完全看不到循环计数**。
|
|
119
|
+
|
|
120
|
+
更可能的原因是:**在 `executeLoopGroup` 中,第一次循环的 `updateWidgetStep` 调用位置不正确**。注意 `executeLoopGroup` 中执行流程:
|
|
121
|
+
|
|
122
|
+
1. `while (loopCount < maxLoops)` — 进入循环,`loopCount=0`
|
|
123
|
+
2. 重置 sub-step 为 pending
|
|
124
|
+
3. 运行 worker agent
|
|
125
|
+
4. 运行 reviewer agent
|
|
126
|
+
5. `loopCount++`(变成 1)
|
|
127
|
+
6. `state.loopCount = loopCount;`(设置为 1)
|
|
128
|
+
7. `updateWidgetStep(...)` — 这里设置 `loopCount=1`
|
|
129
|
+
|
|
130
|
+
在第 3 步运行 worker 期间,`runAgentWithProgress` 会多次调用 `refreshWidget()`(通过 `setWidgetSubStepStatus`、`addWidgetSubStepTool` 等)。这些 refresh 中,widget step 的 `loopCount` 是 **未定义**(`undefined`)的,因为 `updateWidgetStep` 还未被调用。所以在 worker 运行期间,`buildWidgetLines` 读到 `s.loopCount == null`,只看到 `s.maxLoops != null`,然后因为 `isRunning` 为 true,老的代码逻辑(在 commit 01413c9 之前的代码)会显示 `第 1 次循环`,但 commit 01413c9 删除了这个逻辑——**导致整个 worker/reviewer 运行期间 loopCount 完全不可见**。
|
|
131
|
+
|
|
132
|
+
也就是说,在 commit 01413c9 中,这部分代码被删除:
|
|
133
|
+
```
|
|
134
|
+
- if (isRunning) {
|
|
135
|
+
- // Immediately show 第 1 次循环 when loop-group starts
|
|
136
|
+
- loopStr = dim(theme, ` · 第 1 次循环`);
|
|
137
|
+
- }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
移除这个逻辑的意图是"让 `executeLoopGroup` 通过 `updateWidgetStep` 管理 loopCount"。但当 worker/reviewer 运行时(第 3、4 步),`updateWidgetStep` 还未被调用(它在第 7 步才调用),所以 widget 没有 loopCount,也没有显示任何循环计数。
|
|
141
|
+
|
|
142
|
+
修复方案:**恢复 `isRunning` 状态下显示 `第 1 次循环` 的逻辑**(当 `s.loopCount` 为 null/undefined 时),或者更精确地说,在 `isRunning` 状态下,如果 `s.loopCount == null` 但 `s.maxLoops != null`,也显示 `第 1 次循环`。
|
|
143
|
+
|
|
144
|
+
同时,移除 `isRunning` 状态的限制条件:
|
|
145
|
+
```typescript
|
|
146
|
+
if (s.loopCount != null && s.loopCount > 0) {
|
|
147
|
+
loopStr = dim(theme, ` · 第 ${s.loopCount} 次循环`);
|
|
148
|
+
} else if (s.maxLoops != null) {
|
|
149
|
+
if (isRunning) {
|
|
150
|
+
// 当 loop-group 开始运行时,即使 loopCount 尚未通过 updateWidgetStep 设置,
|
|
151
|
+
// 也显示"第 1 次循环"(第 0 次循环仅用于 pending 状态)
|
|
152
|
+
loopStr = dim(theme, ` · 第 1 次循环`);
|
|
153
|
+
} else if (isPending) {
|
|
154
|
+
loopStr = dim(theme, ` · 第 0 次循环`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
这样,当子代理刚刚开始运行时,即便 `loopCount` 还未更新,也能立即显示 `第 1 次循环`。
|
|
160
|
+
|
|
161
|
+
- **验证方式**:运行 `node tests/test-loopcount-timeout-fix.mjs`;阅读修改后的代码确认逻辑正确
|
|
162
|
+
|
|
163
|
+
### 步骤 3:修复 `getGitDiffChanges` 中的 git diff 解析逻辑(workflow-engine.ts)
|
|
164
|
+
|
|
165
|
+
- **前置条件**:无
|
|
166
|
+
- **改动文件**:`extensions/workflow-engine.ts`
|
|
167
|
+
- **改动内容**:
|
|
168
|
+
|
|
169
|
+
**3a. 修改 `git diff --name-status` 的解析。**
|
|
170
|
+
|
|
171
|
+
当前使用正则:
|
|
172
|
+
```typescript
|
|
173
|
+
const match = trimmed.match(/^([MAD])\s+(.+)$/);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`git diff --name-status` 的实际输出格式为 `M\t.gitignore`(即 `status + 制表符 + 文件路径`)。上面的正则中 `\s+` 可以匹配制表符,**实际上不会有匹配问题**。但是根据用户反馈,正则会"识别出来一些无关东西,判断不出来是哪里来的"。
|
|
177
|
+
|
|
178
|
+
更健壮的方案:直接按制表符或空格拆分,取第一个字符为 status,其余为文件路径:
|
|
179
|
+
```typescript
|
|
180
|
+
// Format: "M\tpath/to/file" (tab-separated) or "M path/to/file" (spaces)
|
|
181
|
+
const firstSpace = trimmed.indexOf("\t");
|
|
182
|
+
if (firstSpace < 0) {
|
|
183
|
+
// Try multiple spaces
|
|
184
|
+
const statusChar = trimmed[0];
|
|
185
|
+
if (statusChar === "M" || statusChar === "A" || statusChar === "D") {
|
|
186
|
+
const rest = trimmed.slice(1).trim();
|
|
187
|
+
if (rest) {
|
|
188
|
+
changes.push({ status: statusChar as "M" | "A" | "D", path: rest });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
const status = trimmed.slice(0, firstSpace).trim();
|
|
193
|
+
const path = trimmed.slice(firstSpace + 1).trim();
|
|
194
|
+
if (status && path && !seen.has(path)) {
|
|
195
|
+
seen.add(path);
|
|
196
|
+
changes.push({ status: status as "M" | "A" | "D", path });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**更简单的方案**:由于 `git diff --name-status` 的输出格式保证是 `X\tfilepath\n`,最简单的做法是按 `\t` 拆分:
|
|
202
|
+
```typescript
|
|
203
|
+
// git diff --name-status 输出格式:X\tfilepath\n
|
|
204
|
+
// 直接按制表符拆分,X 是第一个字符,filepath 是第二部分
|
|
205
|
+
const parts = trimmed.split("\t");
|
|
206
|
+
if (parts.length === 2) {
|
|
207
|
+
const status = parts[0]!.trim();
|
|
208
|
+
const filePath = parts[1]!.trim();
|
|
209
|
+
if (filePath && !seen.has(filePath) && (status === "M" || status === "A" || status === "D")) {
|
|
210
|
+
seen.add(filePath);
|
|
211
|
+
changes.push({ status: status as "M" | "A" | "D", path: filePath });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**3b. 修改 `git status --porcelain` 的解析。**
|
|
217
|
+
|
|
218
|
+
同理,`git status --porcelain` 的输出格式为 `XY filepath\n`(如 ` M .gitignore`、`?? newfile.ts`、`A filepath`)。可以用字符串拆分:
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
// git status --porcelain 格式:XY filepath (e.g., " M .gitignore", "?? newfile.ts")
|
|
222
|
+
// 前两个字符是状态,后面的空格分隔,然后是文件路径
|
|
223
|
+
const statusPrefix = trimmed.slice(0, 2); // e.g., "??", " M", "A "
|
|
224
|
+
const filePath = trimmed.slice(3).trim(); // after "?? " or " M "
|
|
225
|
+
if (filePath && !seen.has(filePath) && (statusPrefix === "??" || statusPrefix === "A " || statusPrefix.startsWith("A"))) {
|
|
226
|
+
seen.add(filePath);
|
|
227
|
+
changes.push({ status: "A", path: filePath });
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**注意**:`git status --porcelain` 的前两个字符格式是固定的 `XY`(X 是 index 状态,Y 是 working tree 状态)。`??` 表示 untracked,`A ` 表示 staged new,` M` 表示 modified in working tree 等。
|
|
232
|
+
|
|
233
|
+
但当前代码只关心 "??" 和 "A "(以 "A" 开头),所以直接检查前两个字符即可。
|
|
234
|
+
|
|
235
|
+
- **验证方式**:运行 `node tests/test-loopcount-timeout-fix.mjs`
|
|
236
|
+
|
|
237
|
+
## 依赖关系
|
|
238
|
+
|
|
239
|
+
- 步骤 1、2 修改同一文件(`ui-helpers.ts`),但改动在不同位置,可独立实施
|
|
240
|
+
- 步骤 3 修改不同文件(`workflow-engine.ts`),完全独立于步骤 1、2
|
|
241
|
+
- 三个步骤可以按任意顺序实施,互不依赖
|
|
242
|
+
|
|
243
|
+
## 测试策略
|
|
244
|
+
|
|
245
|
+
- 运行现有测试 `node tests/test-loopcount-timeout-fix.mjs`,确保所有通过
|
|
246
|
+
- 手动审查相关代码路径,确认改动正确
|
|
247
|
+
|
|
248
|
+
## 注意事项
|
|
249
|
+
|
|
250
|
+
- **超时时间在子代理行的显示**:当前的 `sub.detail` 已在 `runAgentWithProgress` 中设置为 `超时时间60m`(具体为 `` 超时时间${formatTimeout(timeoutMs)} ``)。但要注意,`sub.detail` 的显示位置需要调整——当前逻辑是在 `childItems` 为空时才显示 `sub.detail`,作为子项。需要改为直接在 agent 行末尾显示 `(当前计时/${sub.detail})`。同时需要注意:当 sub-step 有 tools/outputs 时,`sub.detail` 不应再作为子项出现(避免重复)。
|
|
251
|
+
- **show detail change**: 当前 `sub.detail` 只在 `childItems.length === 0` 时显示为子项。修改后,`sub.detail` 应被解析为超时信息拼接到 agent 行末尾,而不作为独立的子项。因此需要修改子代理行的渲染逻辑。
|
|
252
|
+
- **子代理超时时间的 data flow**: 当前 `runAgentWithProgress` 已在 sub-step 的 `detail` 字段写入 `超时时间60m`。这个 detail 可以直接重用。但需要区分:`detail` 目前既被当做"超时时间文本"使用,也被当做"其他详情文本"(当没有 tools/outputs 时)。修改后,如果 `detail` 包含 "超时时间",则应解析到 agent 行;否则仍作为普通子项。
|
|
253
|
+
- 更简单的做法是:**直接在内置渲染中为 sub-step 添加超时时间字段**,或者将超时时间单独存为一个字段。但为了最小改动,直接利用现有的 `detail` 字段(已在 `runAgentWithProgress` 中设置为 `超时时间60m`),在 agent 行渲染时拼接。
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# [fix] Esc 双击确认停止工作流 — 实施计划
|
|
2
|
+
|
|
3
|
+
## 概述
|
|
4
|
+
|
|
5
|
+
修复工作流运行期间,一次 Esc 键就立即中断工作流的问题。改为:
|
|
6
|
+
- 第一次按 Esc:显示提示 "再次按下 Esc 键,停止 Workflow"
|
|
7
|
+
- 两次 Esc 间隔 < 5s 才退出
|
|
8
|
+
- 超过 5s 后重置状态,重新监听
|
|
9
|
+
|
|
10
|
+
**改动范围极小**,只修改 `workflow-engine.ts` 中 `runWorkflow` 函数内的 `onTerminalInput` 回调逻辑。
|
|
11
|
+
|
|
12
|
+
## 文件清单
|
|
13
|
+
|
|
14
|
+
### 修改文件
|
|
15
|
+
| 文件路径 | 改动描述 | 风险等级 |
|
|
16
|
+
|---------|---------|---------|
|
|
17
|
+
| `extensions/workflow-engine.ts` | Esc 处理逻辑:增加二次确认、5s 时间窗口、提示显示 | 低 |
|
|
18
|
+
|
|
19
|
+
### 新增文件
|
|
20
|
+
无
|
|
21
|
+
|
|
22
|
+
### 删除文件
|
|
23
|
+
无
|
|
24
|
+
|
|
25
|
+
## 实施步骤
|
|
26
|
+
|
|
27
|
+
### 步骤 1:修改 Esc 处理逻辑(二次确认 + 5s 时间窗口)
|
|
28
|
+
|
|
29
|
+
- **前置条件**:无
|
|
30
|
+
- **改动文件**:`extensions/workflow-engine.ts`
|
|
31
|
+
- **改动内容**:
|
|
32
|
+
|
|
33
|
+
定位到 `runWorkflow` 函数末尾的 `onTerminalInput` 回调(当前第 1709-1718 行):
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
// ── Register terminal input handler (Esc to cancel) ──
|
|
37
|
+
if (ctx.hasUI) {
|
|
38
|
+
_terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => {
|
|
39
|
+
if (!matchesKey(data, Key.escape)) return undefined;
|
|
40
|
+
if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
|
|
41
|
+
ctx.ui.notify("⏹️ 用户取消工作流", "warning");
|
|
42
|
+
cancelWorkflow();
|
|
43
|
+
return { consume: true };
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**改为**(关键改动):
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// ── Register terminal input handler (Esc to cancel, with double-press confirmation) ──
|
|
54
|
+
if (ctx.hasUI) {
|
|
55
|
+
let _lastEscPressTime = 0;
|
|
56
|
+
_terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => {
|
|
57
|
+
if (!matchesKey(data, Key.escape)) return undefined;
|
|
58
|
+
if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
if (_lastEscPressTime > 0 && now - _lastEscPressTime < 5000) {
|
|
61
|
+
// Second Esc press within 5s → confirm cancel
|
|
62
|
+
ctx.ui.notify("⏹️ 正在停止工作流...", "warning");
|
|
63
|
+
cancelWorkflow();
|
|
64
|
+
_lastEscPressTime = 0;
|
|
65
|
+
return { consume: true };
|
|
66
|
+
}
|
|
67
|
+
// First Esc press (or expired) → show hint
|
|
68
|
+
_lastEscPressTime = now;
|
|
69
|
+
ctx.ui.notify("再次按下 Esc 键,停止 Workflow", "warning");
|
|
70
|
+
return { consume: true };
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- **改动说明**:
|
|
78
|
+
1. 引入 `_lastEscPressTime` 变量(函数块作用域),记录上一次 Esc 按下的时间
|
|
79
|
+
2. 第一次按 Esc → 记录时间并显示提示 "再次按下 Esc 键,停止 Workflow"
|
|
80
|
+
3. 在 5s 内再次按 Esc → 执行取消操作
|
|
81
|
+
4. 超过 5s 后按 Esc → 重置为第一次状态(因为 `_lastEscPressTime > 0` 但差值 >= 5000,被视为过期,走到 `_lastEscPressTime = now` 分支重新计时)
|
|
82
|
+
|
|
83
|
+
- **验证方式**:
|
|
84
|
+
1. 手动测试:启动工作流,按一次 Esc → 应看到提示,工作流继续
|
|
85
|
+
2. 手动测试:5s 内再按一次 Esc → 工作流取消
|
|
86
|
+
3. 手动测试:按一次 Esc,等待 5s+,再按一次 Esc → 相当于第一次,显示提示
|
|
87
|
+
4. 确保原有取消功能在二次确认后正常运作(清理 widget、保存 checkpoint、归档)
|
|
88
|
+
5. 确保 Esc 在其他非工作流场景的行为不受影响(只修改了 `_workflowRunning` 为 true 时的分支)
|
|
89
|
+
|
|
90
|
+
### 步骤 2:清理 `_lastEscPressTime` 状态
|
|
91
|
+
|
|
92
|
+
- **前置条件**:步骤 1 完成
|
|
93
|
+
- **改动文件**:`extensions/workflow-engine.ts`
|
|
94
|
+
- **改动内容**:
|
|
95
|
+
|
|
96
|
+
在 `cleanupWidget()` 函数中,确保 `_lastEscPressTime` 在 cleanup 时会被自然重置。但由于 `_lastEscPressTime` 是 `onTerminalInput` 回调闭包内的局部变量,当 `_terminalInputUnsubscribe()` 被调用时,闭包和 `_lastEscPressTime` 都会自然被 GC 回收。
|
|
97
|
+
|
|
98
|
+
**不需要额外改动**——`cleanupWidget()` 中已有的逻辑:
|
|
99
|
+
```typescript
|
|
100
|
+
if (_terminalInputUnsubscribe) {
|
|
101
|
+
_terminalInputUnsubscribe();
|
|
102
|
+
_terminalInputUnsubscribe = null;
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
已经在工作流结束时正确地解除了监听器注册。下次 `runWorkflow` 被调用时,会新建一个闭包和新的 `_lastEscPressTime` 变量。
|
|
106
|
+
|
|
107
|
+
- **验证方式**:
|
|
108
|
+
1. 运行工作流,按 Esc 两次确认取消
|
|
109
|
+
2. 确认所有 cleanup 逻辑正确执行
|
|
110
|
+
3. 启动新的工作流,测试 Esc 逻辑从头开始工作
|
|
111
|
+
|
|
112
|
+
## 依赖关系
|
|
113
|
+
|
|
114
|
+
- 步骤 2 是验证步骤,不涉及代码改动
|
|
115
|
+
- 仅需修改一个函数回调中的逻辑
|
|
116
|
+
|
|
117
|
+
## 测试策略
|
|
118
|
+
|
|
119
|
+
1. **人工测试(主要方式)**:
|
|
120
|
+
- 启动一个工作流(如 `/dev-feat` → 快速链式)
|
|
121
|
+
- 按 Esc → 验证显示提示 "再次按下 Esc 键,停止 Workflow"
|
|
122
|
+
- 工作流继续正常运行
|
|
123
|
+
- 5s 内再按 Esc → 工作流取消,widget 消失
|
|
124
|
+
- 重新启动工作流,按 Esc,等 5s+,再按 Esc → 显示提示(重置为第一次)
|
|
125
|
+
|
|
126
|
+
2. **单元测试** :
|
|
127
|
+
- 因 `onTerminalInput` 回调直接依赖 `ctx.ui` 和终端环境,不方便做纯单元测试
|
|
128
|
+
- 可考虑在 `tests/` 目录下新增测试文件对回调逻辑进行隔离测试(mock `matchesKey`、`_workflowRunning` 等)
|
|
129
|
+
|
|
130
|
+
## 注意事项
|
|
131
|
+
|
|
132
|
+
- **最小改动原则**:只修改 `runWorkflow` 内的 `onTerminalInput` 回调,约 15 行代码
|
|
133
|
+
- **性能**:无影响,仅增加一个 `Date.now()` 调用和简单比较
|
|
134
|
+
- **并发安全**:`_workflowRunning` 是整个模块级别的标志,`_lastEscPressTime` 是闭包局部变量,不存在竞态问题
|
|
135
|
+
- **不要影响其他 Esc 处理**:其他地方的 Esc 处理(如 `uiSelect`、`uiConfirm` 中的 `onEscape` 回调)完全不受影响,它们由不同的 TUI 组件管理
|
|
136
|
+
- **不要破坏工作流的正常运行**:第一次 Esc 只是显示提示、不执行任何取消操作,工作流步骤继续执行
|
|
137
|
+
- **不要改变 notify 行为**:使用已有的 `ctx.ui.notify()` API,与代码库中其他通知一致
|