agentic-flow-kit 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +92 -23
  3. package/bin/flow-kit.mjs +3 -1
  4. package/locales/en/installer.json +87 -2
  5. package/locales/en/overrides/.codebuddy/skills/tfs-pr/SKILL.md +1 -1
  6. package/locales/en/overrides/workflow/README.md +2 -0
  7. package/locales/zh-CN/installer.json +87 -2
  8. package/package.json +2 -2
  9. package/schemas/install-manifest.schema.json +3 -1
  10. package/schemas/project-profile.schema.json +1 -1
  11. package/src/cli/args.mjs +3 -1
  12. package/src/cli/main.mjs +9 -2
  13. package/src/commands/doctor.mjs +50 -4
  14. package/src/commands/install.mjs +160 -13
  15. package/src/hosts/registry.mjs +154 -10
  16. package/src/i18n.mjs +3 -0
  17. package/src/install/apply.mjs +17 -3
  18. package/src/install/frontmatter.mjs +84 -0
  19. package/src/install/plan.mjs +174 -16
  20. package/src/models/known.mjs +13 -0
  21. package/src/models/probe.mjs +163 -0
  22. package/src/models/resolve.mjs +312 -0
  23. package/src/render/tokens.mjs +56 -9
  24. package/src/tui/ansi.mjs +55 -0
  25. package/src/tui/flows.mjs +306 -0
  26. package/src/tui/prompts.mjs +139 -0
  27. package/src/tui/screen.mjs +217 -0
  28. package/src/tui/widgets.mjs +111 -0
  29. package/src/tui/wizard.mjs +68 -0
  30. package/templates/engine/.agents/commands/new-task.md +2 -2
  31. package/templates/engine/.agents/commands/pipeline-run.md +5 -1
  32. package/templates/engine/.agents/commands/stages.md +2 -1
  33. package/templates/engine/.agents/config/agent-eval.json +17 -0
  34. package/templates/engine/.agents/config/mechanism-registry.md +2 -1
  35. package/templates/engine/.agents/config/memory-tiers.json +13 -0
  36. package/templates/engine/.agents/config/pipeline.json +36 -0
  37. package/templates/engine/.agents/config/steering.json +28 -0
  38. package/templates/engine/.agents/evals/README.md +49 -0
  39. package/templates/engine/.agents/evals/tasks.tsv +6 -0
  40. package/templates/engine/.agents/hooks/pre-write-check.cjs +27 -0
  41. package/templates/engine/.agents/notes/injected-memory.md +14 -0
  42. package/templates/engine/.agents/notes/steering-tiers.md +28 -0
  43. package/templates/engine/.agents/scripts/agent-eval.mjs +268 -0
  44. package/templates/engine/.agents/scripts/check-pipeline-state.mjs +85 -0
  45. package/templates/engine/.agents/scripts/claims.mjs +166 -0
  46. package/templates/engine/.agents/scripts/dream-run.mjs +78 -3
  47. package/templates/engine/.agents/scripts/fix-task.mjs +77 -0
  48. package/templates/engine/.agents/scripts/memory-tier.mjs +196 -0
  49. package/templates/engine/.agents/scripts/memory-wiki.mjs +296 -0
  50. package/templates/engine/.agents/scripts/outcome-metrics.mjs +195 -0
  51. package/templates/engine/.agents/scripts/path-lease.mjs +177 -0
  52. package/templates/engine/.agents/scripts/session-log.mjs +157 -0
  53. package/templates/engine/.agents/scripts/steering.mjs +75 -0
  54. package/templates/engine/.agents/scripts/verify.mjs +16 -5
  55. package/templates/engine/.agents/skills/arena/SKILL.md +4 -0
  56. package/templates/engine/.agents/skills/dream/SKILL.md +21 -2
  57. package/templates/engine/.agents/skills/eval/SKILL.md +16 -0
  58. package/templates/engine/.agents/skills/model-upgrade/SKILL.md +41 -0
  59. package/templates/engine/.agents/skills/session-resume/SKILL.md +7 -0
  60. package/templates/engine/.agents/skills/show-me-your-work/SKILL.md +14 -0
  61. package/templates/engine/.agents/skills/wiki/SKILL.md +4 -1
  62. package/templates/git/.gitattributes +1 -0
  63. package/templates/git/.githooks/package.json +3 -0
  64. package/templates/git/.github/workflows/ci.yml +32 -0
  65. package/templates/git/azure-pipelines.yml +46 -0
  66. package/templates/host-meta/SKILLS.md +3 -0
  67. package/templates/host-meta/claude/skill-pointer.md +6 -0
  68. package/templates/host-meta/cline/skill-index.md +6 -0
  69. package/templates/host-meta/codebuddy/skill-pointer.md +6 -0
  70. package/templates/host-meta/commandcode/skill-index.md +5 -0
  71. package/templates/host-meta/opencode/skill-pointer.md +6 -0
  72. package/templates/host-meta/qoder/skill-index.md +5 -0
  73. package/templates/host-meta/qwen/skill-index.md +5 -0
  74. package/templates/host-meta/trae/skill-index.md +6 -0
  75. package/templates/hosts/claude/settings.hardened.json +94 -0
  76. package/templates/hosts/claude/settings.json +1 -3
  77. package/templates/hosts/commandcode/settings.json +1 -3
  78. package/templates/hosts/qoder/settings.json +1 -3
  79. package/templates/hosts/qwen/settings.json +1 -3
  80. package/templates/profiles/go/AGENTS.md +52 -0
  81. package/templates/profiles/java/AGENTS.md +52 -0
  82. package/templates/profiles/rust/AGENTS.md +52 -0
  83. package/templates/profiles/typescript/AGENTS.md +52 -0
  84. package/templates/scaffold/workflow/INDEX.md +16 -0
  85. package/templates/scaffold/workflow/README.md +2 -0
  86. package/templates/scaffold/workflow/artifact-sources.md +25 -0
  87. package/templates/scaffold/workflow/non-engineer-intent.md +51 -0
@@ -0,0 +1,306 @@
1
+ // TUI 业务流:安装 / 升级 / 追加宿主 / 体检 / 宿主列表,以及 FLOWS 派发表。
2
+ // 只经 prompts 层问答、screen.print 输出与各 command 入口执行,不直接触碰终端状态。
3
+ import { visualWidth, box, green, red, progressLine } from './ansi.mjs';
4
+ import { pickOne, pickMany, confirmYes, showLines, askText } from './prompts.mjs';
5
+ import { HOSTS, HOST_NAMES } from '../hosts/registry.mjs';
6
+ import { probeModels } from '../models/probe.mjs';
7
+ import { ROLE_NAMES, loadRoleConfig, recommendRoles, summarizeModels } from '../models/resolve.mjs';
8
+ import { readManifest } from '../install/manifest.mjs';
9
+ import { detectStackDetail, STACKS, stackMarkerLabel, formatInstallLog, installLogNotable, installCommand } from '../commands/install.mjs';
10
+ import { stackDescription } from '../render/tokens.mjs';
11
+ import { LANGUAGES } from '../i18n.mjs';
12
+ import { doctorCommand } from '../commands/doctor.mjs';
13
+ import { addHostCommand } from '../commands/add-host.mjs';
14
+ import { upgradeCommand } from '../commands/upgrade.mjs';
15
+
16
+ // flows 内部 argv 统一经 baseArgs 构造(形状与 cli/args.mjs parseArgs 一致:flag() 依赖 args.flags)
17
+ export const baseArgs = () => ({ _: [], agents: [], flags: new Set() });
18
+
19
+ function fileOf(msg) {
20
+ const text = String(msg ?? '');
21
+ const i = text.indexOf('(');
22
+ return (i >= 0 ? text.slice(0, i) : text).trim();
23
+ }
24
+
25
+ function clip(text, max) {
26
+ if (visualWidth(text) <= max) return text;
27
+ let out = '';
28
+ let width = 0;
29
+ for (const ch of text) {
30
+ const w = visualWidth(ch);
31
+ if (width + w > max - 1) return `${out}…`;
32
+ out += ch;
33
+ width += w;
34
+ }
35
+ return out;
36
+ }
37
+
38
+ // 常规创建/更新只留在进度条上。冲突和非常规跳过先换行落盘,避免被下一帧 \\r 清掉。
39
+ export function installProgressWrites({ action, msg, done, total }) {
40
+ const writes = [];
41
+ if (installLogNotable(action, msg)) {
42
+ const text = formatInstallLog(action, msg);
43
+ writes.push(`\r\x1b[2K${action === 'conflict' ? red(text) : text}\n`);
44
+ }
45
+ const detail = clip(formatInstallLog(action, fileOf(msg)), 48);
46
+ const line = progressLine(done, total, detail);
47
+ writes.push(done === total ? `${line}\n` : line);
48
+ return writes;
49
+ }
50
+
51
+ // 栈菜单:名称在 label,hint 带典型标记与描述。命中项改写为实际文件名。
52
+ export function stackItems({ detected, current, lang, t }) {
53
+ return STACKS.map((stack) => {
54
+ const matched = detected?.stack === stack;
55
+ const tags = [];
56
+ if (current && stack === current) tags.push(t('tui.currentStack'));
57
+ if (matched && detected.file) tags.push(t('tui.detectedFile', { file: detected.file }));
58
+ else if (matched) tags.push(t('tui.detected'));
59
+ else {
60
+ const marker = stackMarkerLabel(stack);
61
+ if (marker) tags.push(marker);
62
+ }
63
+ const hint = [...tags, stackDescription(stack, lang)].filter(Boolean).join(' · ');
64
+ return { value: stack, label: t(`tui.stack.${stack}`), hint };
65
+ });
66
+ }
67
+
68
+ // 宿主候选项单一映射:已安装的提示「已安装」,否则提示 label → dir
69
+ function hostItems({ installed = [], t }) {
70
+ return HOST_NAMES.map((n) => ({
71
+ value: n,
72
+ label: n,
73
+ hint: installed.includes(n) ? t('tui.installed') : `${HOSTS[n].label} → ${HOSTS[n].dir}`,
74
+ checked: installed.includes(n),
75
+ }));
76
+ }
77
+
78
+ // 模型配置流:静态探测 → 可选只读 CLI 探测 → 推荐确认 / 逐角色选择(含手填、inherit、跳过)。
79
+ async function modelFlow(ctx, hosts) {
80
+ const { t } = ctx;
81
+ const cwd = process.cwd();
82
+ const roleConfig = loadRoleConfig(cwd);
83
+ const models = {};
84
+ let explicit = false;
85
+
86
+ for (const host of hosts) {
87
+ const h = HOSTS[host];
88
+ if (!h?.agents?.modelField) continue;
89
+
90
+ let candidates = probeModels({ host, target: cwd, allowCli: false });
91
+ if (!candidates.length && (h.modelProbe?.cli || []).length) {
92
+ const allow = await confirmYes(ctx, t('tui.model.allowProbe', { host }));
93
+ if (allow === null) return null;
94
+ if (allow) candidates = probeModels({ host, target: cwd, allowCli: true });
95
+ }
96
+
97
+ const recommended = recommendRoles({ host, candidates, roleConfig });
98
+ const hasRecommended = ROLE_NAMES.some((role) => recommended[role]);
99
+ let useRecommended = false;
100
+ if (hasRecommended) {
101
+ useRecommended = await confirmYes(ctx, t('tui.model.useRecommended', { host }));
102
+ if (useRecommended === null) return null;
103
+ }
104
+ if (useRecommended) {
105
+ models[host] = recommended;
106
+ continue;
107
+ }
108
+
109
+ explicit = true;
110
+ const values = {};
111
+ for (const role of ROLE_NAMES) {
112
+ const items = candidates.map((c) => ({ value: c.id, label: c.id, hint: c.source }));
113
+ if (recommended[role] && !items.some((it) => it.value === recommended[role])) {
114
+ items.push({ value: recommended[role], label: recommended[role], hint: t('tui.model.recommended') });
115
+ }
116
+ if (h.agents.inheritValue) items.push({ value: h.agents.inheritValue, label: t('tui.model.inherit') });
117
+ items.push({ value: '__manual__', label: t('tui.model.manual') });
118
+ items.push({ value: '__skip__', label: t('tui.model.skip') });
119
+
120
+ const choice = await pickOne(ctx, {
121
+ question: t('tui.model.role', { role: t(`tui.model.role.${role}`) }),
122
+ hint: t('tui.hint.moveEnter'),
123
+ items,
124
+ });
125
+ if (choice === null) return null;
126
+ if (choice === '__skip__') {
127
+ values[role] = '__none__';
128
+ } else if (choice === '__manual__') {
129
+ const value = await askText(ctx, {
130
+ question: t('tui.model.manualPrompt'),
131
+ validate: (v) => (String(v).trim() ? null : t('tui.model.invalid')),
132
+ });
133
+ if (value === null) return null;
134
+ values[role] = value.trim();
135
+ } else {
136
+ values[role] = choice;
137
+ }
138
+ }
139
+ // 跳过 / inherit 不是具体模型,不触发同模型告警。
140
+ const concrete = (v) => Boolean(v) && v !== '__none__' && v !== h.agents.inheritValue;
141
+ if (concrete(values.review) && concrete(values.code) && values.review === values.code) {
142
+ showLines(ctx, [red(t('tui.model.sameAsCode'))]);
143
+ }
144
+ models[host] = values;
145
+ }
146
+
147
+ return { models, explicit };
148
+ }
149
+
150
+ // 安装流:宿主 → 语言 → 栈 → 模型 → 开关 → 计划确认 → 执行(进度行)→ 可选体检
151
+ async function installFlow(ctx) {
152
+ const { screen, t, lang } = ctx;
153
+ const cwd = process.cwd();
154
+ const prev = readManifest(cwd);
155
+ const prevHosts = Array.isArray(prev && prev.hosts) ? prev.hosts : [];
156
+
157
+ const hosts = await pickMany(ctx, {
158
+ question: t('tui.hostsQuestion'),
159
+ hint: t('tui.hint.toggle'),
160
+ min: 1,
161
+ items: hostItems({ installed: prevHosts, t }),
162
+ });
163
+ if (hosts === null) return;
164
+
165
+ const selLang = await pickOne(ctx, {
166
+ question: t('tui.langQuestion'),
167
+ hint: t('tui.hint.moveEnter'),
168
+ items: LANGUAGES.map((l) => ({ value: l, label: l })),
169
+ });
170
+ if (selLang === null) return;
171
+
172
+ const detected = detectStackDetail(cwd);
173
+ const stack = await pickOne(ctx, {
174
+ question: t('tui.stackQuestion'),
175
+ hint: t('tui.hint.moveEnter'),
176
+ cursor: Math.max(0, STACKS.indexOf(detected.stack)),
177
+ items: stackItems({ detected, lang, t }),
178
+ });
179
+ if (stack === null) return;
180
+
181
+ const modelResult = await modelFlow(ctx, hosts);
182
+ if (modelResult === null) return;
183
+
184
+ const opts = await pickMany(ctx, {
185
+ question: t('tui.optsQuestion'),
186
+ hint: t('tui.hint.toggle'),
187
+ min: 0,
188
+ items: [
189
+ { value: 'git-config', label: t('tui.opt.gitConfig'), checked: true },
190
+ { value: 'dry-run', label: t('tui.opt.dryRun') },
191
+ { value: 'force', label: t('tui.opt.force') },
192
+ ],
193
+ });
194
+ if (opts === null) return;
195
+
196
+ const dryRun = opts.includes('dry-run');
197
+ // argv 形状与 wizard.mjs buildInstallArgs 一致:git 配置默认开,开关转 flags
198
+ const args = { ...baseArgs(), _: ['install'], agents: hosts, lang: selLang, stack };
199
+ if (modelResult.models && Object.keys(modelResult.models).length) args.tuiModels = modelResult.models;
200
+ if (!opts.includes('git-config')) args.flags.add('no-git-config');
201
+ if (dryRun) args.flags.add('dry-run');
202
+ if (opts.includes('force')) args.flags.add('force');
203
+
204
+ const planLines = [
205
+ t('tui.plan.target', { target: cwd }),
206
+ t('tui.plan.hosts', { hosts: hosts.join(', ') }),
207
+ t('tui.plan.lang', { lang: selLang }),
208
+ t('tui.plan.stack', { stack }),
209
+ ];
210
+ if (modelResult.models && Object.keys(modelResult.models).length) {
211
+ planLines.push(t('tui.plan.models', { models: summarizeModels({ hosts: modelResult.models }) }));
212
+ }
213
+ showLines(ctx, box(t('tui.planTitle'), planLines));
214
+ if (!(await confirmYes(ctx, t('tui.confirmInstall')))) return;
215
+
216
+ screen.print(t('tui.running') + '\n');
217
+ // 进度回调契约 (action, msg, done, total):进度条带当前文件;冲突与非常规跳过单独留行。
218
+ await installCommand({
219
+ args,
220
+ t,
221
+ lang: selLang,
222
+ onLog: (action, msg, done, total) => {
223
+ for (const chunk of installProgressWrites({ action, msg, done, total })) screen.output.write(chunk);
224
+ },
225
+ });
226
+ // dry-run 未写盘,完成文案须区分,避免误导
227
+ screen.print(green(`✓ ${t(dryRun ? 'tui.dryDone' : 'tui.done')}`) + '\n');
228
+
229
+ screen.print('');
230
+ if (await confirmYes(ctx, t('tui.askDoctor'))) {
231
+ return doctorCommand({ args: { deep: false }, t, lang: selLang });
232
+ }
233
+ }
234
+
235
+ async function upgradeFlow(ctx) {
236
+ const { t, lang } = ctx;
237
+ const cwd = process.cwd();
238
+ const manifest = readManifest(cwd);
239
+ if (!manifest) throw new Error(t('error.noManifest'));
240
+ const recorded = STACKS.includes(manifest.stack) ? manifest.stack : 'empty';
241
+ const detected = detectStackDetail(cwd);
242
+ const stack = await pickOne(ctx, {
243
+ question: t('tui.upgradeStackQuestion'),
244
+ hint: t('tui.hint.moveEnter'),
245
+ cursor: Math.max(0, STACKS.indexOf(recorded)),
246
+ items: stackItems({ detected, current: recorded, lang, t }),
247
+ });
248
+ if (stack === null) return;
249
+ showLines(ctx, box(t('tui.planTitle'), [
250
+ t('tui.plan.hosts', { hosts: (manifest.hosts || []).join(', ') }),
251
+ t('tui.plan.lang', { lang: manifest.language || lang }),
252
+ t('tui.plan.stack', { stack }),
253
+ ]));
254
+ if (!(await confirmYes(ctx, t('tui.confirmUpgrade')))) return;
255
+ return upgradeCommand({ args: { ...baseArgs(), stack }, t, lang });
256
+ }
257
+
258
+ async function addHostFlow(ctx) {
259
+ const { t, lang } = ctx;
260
+ const manifest = readManifest(process.cwd());
261
+ if (!manifest) throw new Error(t('error.noManifest'));
262
+ const installed = Array.isArray(manifest.hosts) ? manifest.hosts : [];
263
+ const agents = await pickMany(ctx, {
264
+ question: t('tui.addHostQuestion'),
265
+ hint: t('tui.hint.toggle'),
266
+ min: 1,
267
+ items: hostItems({ installed, t }),
268
+ });
269
+ if (agents === null) return;
270
+ const modelResult = await modelFlow(ctx, agents);
271
+ if (modelResult === null) return;
272
+ const args = { ...baseArgs(), _: ['add-host'], agents };
273
+ if (modelResult.models && Object.keys(modelResult.models).length) args.tuiModels = modelResult.models;
274
+ return addHostCommand({ args, t, lang });
275
+ }
276
+
277
+ async function doctorFlow(ctx) {
278
+ const { t, lang } = ctx;
279
+ const deep = await pickOne(ctx, {
280
+ question: t('tui.doctorQuestion'),
281
+ hint: t('tui.hint.moveEnter'),
282
+ items: [
283
+ { value: false, label: t('tui.doctor.normal') },
284
+ { value: true, label: t('tui.doctor.deep') },
285
+ ],
286
+ });
287
+ if (deep === null) return;
288
+ return doctorCommand({ args: { deep }, t, lang });
289
+ }
290
+
291
+ function listHostsFlow(ctx) {
292
+ const { t } = ctx;
293
+ const rows = HOST_NAMES.map((n) => ({ name: n, label: HOSTS[n].label, dir: HOSTS[n].dir }));
294
+ const wName = Math.max(...rows.map((r) => visualWidth(r.name)));
295
+ const wLabel = Math.max(...rows.map((r) => visualWidth(r.label)));
296
+ const pad = (s, w) => s + ' '.repeat(w - visualWidth(s) + 2);
297
+ showLines(ctx, box(t('listHosts.title'), rows.map((r) => `${pad(r.name, wName)}${pad(r.label, wLabel)}${r.dir}`)));
298
+ }
299
+
300
+ export const FLOWS = {
301
+ install: installFlow,
302
+ upgrade: upgradeFlow,
303
+ 'add-host': addHostFlow,
304
+ doctor: doctorFlow,
305
+ 'list-hosts': listHostsFlow,
306
+ };
@@ -0,0 +1,139 @@
1
+ // 问答原语层:交互路径走 keypress widget(经 screen.mjs 的 runPrompt),
2
+ // 非交互终端降级为「编号 + 回车」读行问答。本层只经 screen 输出,不直接触碰终端状态。
3
+ import { box, dim, red } from './ansi.mjs';
4
+ import { createSelect, createMultiSelect, createConfirm, createTextInput } from './widgets.mjs';
5
+ import { runPrompt } from './screen.mjs';
6
+
7
+ // 降级问答原语:每轮打印「题干 + 选项行 + 行内提示」→ 读一行 → parse 裁决。
8
+ // parse 返回 { value } 结束;{ error } 红字重试;{ silent } 静默重印(如切换勾选后刷新行);
9
+ // undefined 视为非法输入,红字提示后重试。EOF(stdin 关闭)一律返回 null。
10
+ async function askLine(ctx, { question = '', rows = () => [], prompt, parse }) {
11
+ const { screen, t, reader } = ctx;
12
+ while (true) {
13
+ const lines = [...(question ? [question] : []), ...rows()];
14
+ if (lines.length) screen.output.write(lines.join('\n') + '\n');
15
+ screen.output.write(prompt);
16
+ const line = await reader();
17
+ if (line === null) return null;
18
+ const result = parse(line.trim());
19
+ if (result === undefined) screen.output.write(`${red(t('tui.invalidNumber'))}\n`);
20
+ else if (result.error) screen.output.write(`${red(result.error)}\n`);
21
+ else if (!result.silent) return result.value;
22
+ }
23
+ }
24
+
25
+ // 降级三问:只保留 rows 生成与 parse,循环/读行/EOF/重试归 askLine
26
+ async function askSelectDegraded(ctx, { question, items }) {
27
+ const { t } = ctx;
28
+ return askLine(ctx, {
29
+ question,
30
+ rows: () => items.map((it, i) => ` ${i + 1}. ${it.label}${it.hint ? ` ${dim(it.hint)}` : ''}`),
31
+ prompt: `${t('tui.inputNumber')} `,
32
+ parse: (s) => {
33
+ const idx = Number(s) - 1;
34
+ return Number.isInteger(idx) && idx >= 0 && idx < items.length ? { value: items[idx].value } : undefined;
35
+ },
36
+ });
37
+ }
38
+
39
+ async function askMultiSelectDegraded(ctx, { question, items, min = 1 }) {
40
+ const { t } = ctx;
41
+ const checked = items.map((it) => it.checked === true);
42
+ return askLine(ctx, {
43
+ question,
44
+ rows: () => items.map((it, i) => ` ${i + 1}. [${checked[i] ? 'x' : ' '}] ${it.label}${it.hint ? ` ${dim(it.hint)}` : ''}`),
45
+ prompt: `${t('tui.toggleNumber')} `,
46
+ parse: (s) => {
47
+ if (s === '') {
48
+ const value = items.filter((_, i) => checked[i]).map((it) => it.value);
49
+ return value.length < min ? { error: t('tui.minSelection', { min }) } : { value };
50
+ }
51
+ const idx = Number(s) - 1;
52
+ if (Number.isInteger(idx) && idx >= 0 && idx < items.length) {
53
+ checked[idx] = !checked[idx];
54
+ return { silent: true };
55
+ }
56
+ return undefined;
57
+ },
58
+ });
59
+ }
60
+
61
+ async function askConfirmDegraded(ctx, label, defaultYes = true) {
62
+ return askLine(ctx, {
63
+ prompt: `${label} ${defaultYes ? '[Y/n]' : '[y/N]'} `,
64
+ parse: (s) => {
65
+ if (!s) return { value: defaultYes };
66
+ if (s === 'y' || s === 'yes') return { value: true };
67
+ if (s === 'n' || s === 'no') return { value: false };
68
+ return undefined; // 非法输入红字提示后重试(原为静默重印,统一归 askLine)
69
+ },
70
+ });
71
+ }
72
+
73
+ // 交互 / 降级统一入口;降级路径 EOF 一律返回 null
74
+ export function pickOne(ctx, { question, hint, items, cursor = 0 }) {
75
+ const { screen, t } = ctx;
76
+ if (screen.interactive) {
77
+ return runPrompt(screen, { title: t('tui.title'), question, hint, widget: createSelect({ items, cursor }) });
78
+ }
79
+ return askSelectDegraded(ctx, { question, items });
80
+ }
81
+
82
+ export function pickMany(ctx, { question, hint, items, min = 1 }) {
83
+ const { screen, t } = ctx;
84
+ if (screen.interactive) {
85
+ return runPrompt(screen, {
86
+ title: t('tui.title'),
87
+ question,
88
+ hint,
89
+ widget: createMultiSelect({ items, min, minMessage: t('tui.minSelection', { min }) }),
90
+ });
91
+ }
92
+ return askMultiSelectDegraded(ctx, { question, items, min });
93
+ }
94
+
95
+ export function confirmYes(ctx, label, defaultYes = true) {
96
+ const { screen, t } = ctx;
97
+ if (screen.interactive) {
98
+ return runPrompt(screen, {
99
+ title: t('tui.title'),
100
+ question: label,
101
+ hint: t('tui.hint.moveEnter'),
102
+ widget: createConfirm({ yesLabel: t('tui.yes'), noLabel: t('tui.no'), defaultYes }),
103
+ });
104
+ }
105
+ return askConfirmDegraded(ctx, label, defaultYes);
106
+ }
107
+
108
+ export function askText(ctx, { question, hint, initial = '', placeholder = '', validate = () => null }) {
109
+ const { screen, t } = ctx;
110
+ if (screen.interactive) {
111
+ return runPrompt(screen, {
112
+ title: t('tui.title'),
113
+ question,
114
+ hint: hint || t('tui.hint.typeEnter'),
115
+ widget: createTextInput({ initial, placeholder, validate }),
116
+ });
117
+ }
118
+ return askLine(ctx, {
119
+ question,
120
+ rows: () => (placeholder ? [dim(placeholder)] : []),
121
+ prompt: `${t('tui.hint.typeEnter')} `,
122
+ parse: (s) => {
123
+ const err = validate(s);
124
+ return err ? { error: err } : { value: s };
125
+ },
126
+ });
127
+ }
128
+
129
+ export function showLines(ctx, lines) {
130
+ if (ctx.screen.interactive) ctx.screen.render(lines);
131
+ else ctx.screen.output.write(lines.join('\n') + '\n');
132
+ }
133
+
134
+ export function showError(ctx, err) {
135
+ const { t } = ctx;
136
+ const message = err && err.message ? err.message : String(err);
137
+ // 多行 message 先拆行再进 box,避免转义后残影错位
138
+ showLines(ctx, box(t('tui.errorTitle'), message.split('\n').map(red)));
139
+ }
@@ -0,0 +1,217 @@
1
+ // 终端屏幕层:全仓唯一接触 stdin/stdout 的地方(终端状态机 + IO)。
2
+ // render 记住上次行数,重绘时上移清行重写(行数变化会清残留行);print 重置渲染原点后写原始输出,
3
+ // 供 flows 在命令输出推进过光标时新起一块,无需感知光标行数协议。
4
+ // 降级模式在 setRawMode 不可用或非 TTY 直调时进入:交互全部改走「编号 + 回车」问答(prompts.mjs)。
5
+ // 视口高度裁剪明确不做:内容有界(最多 10 宿主选项 + 提示行),裁剪反而引入滚动定位复杂度。
6
+ // 交互按键弃用内置 readline.emitKeypressEvents、改用自研解码器(attachKeyDecoder):内置解码器在部分
7
+ // Windows 终端 / Node 组合下对分片到达与 SS3 序列产出 undefined key(方向键 ↑↓ 失效),且事件契约不变。
8
+ // 解码器不监听 stdin close:真实 EOF 由上层兜底(runPrompt 的 close → QuitError、降级路径的 eof),
9
+ // 虚假 close(Windows 同步子进程后控制台句柄抖动)不得终止解码,否则 doctor 后的提问会冻结。
10
+ import readline from 'node:readline';
11
+ import { StringDecoder } from 'node:string_decoder';
12
+ import { box, cyan, dim } from './ansi.mjs';
13
+
14
+ // Ctrl+C / EOF 的退出哨兵:wizardCommand 捕获后返回 0,不裸抛堆栈
15
+ export class QuitError extends Error {}
16
+
17
+ // 方向键 / Home / End 映射:CSI(\x1b[<params><final>)与 SS3(\x1bO<final>,老终端)的终止字节 → key.name
18
+ const SEQUENCE_KEYS = { A: 'up', B: 'down', C: 'right', D: 'left', H: 'home', F: 'end' };
19
+
20
+ // 自研轻量按键解码器:监听 stdin 原始 data,解出 key 对象后逐个 emit ('keypress', key),
21
+ // 事件契约与内置解码器一致(runPrompt / widgets 无感知)。返回 detach:仅供 restore 摘监听、清计时。
22
+ // 不监听 close:真实 EOF 由 runPrompt 兜底,虚假 close(Windows 同步子进程控制台句柄抖动)不得终止解码。
23
+ function attachKeyDecoder(input) {
24
+ const decoder = new StringDecoder('utf8'); // 多字节字符跨 chunk 边界在此拼合
25
+ let buf = ''; // 未解完的余量(不完整转义序列前缀 / 孤立 ESC)
26
+ let escTimer = null; // 孤立 ESC 的 50ms 等待计时
27
+ const emit = (key) => {
28
+ if (process.env.FLOW_KIT_KEY_DEBUG) console.error('[flow-kit key] emit:', JSON.stringify(key));
29
+ input.emit('keypress', key);
30
+ };
31
+ const onEscTimeout = () => {
32
+ escTimer = null;
33
+ if (buf !== '\x1b') return; // 防御:等待期内已无孤立 ESC(正常应在 data 到达时清计时)
34
+ buf = '';
35
+ emit({ name: 'escape', sequence: '\x1b' });
36
+ };
37
+ const parse = () => {
38
+ while (buf.length > 0) {
39
+ if (buf[0] === '\x1b') {
40
+ if (buf.length === 1) {
41
+ // 只剩孤立 ESC:50ms 内无后续 → escape;期间 data 到达则清计时续解为序列
42
+ if (escTimer === null) escTimer = setTimeout(onEscTimeout, 50);
43
+ return;
44
+ }
45
+ if (buf[1] === '[' || buf[1] === 'O') {
46
+ // CSI 终止字节 0x40-0x7E,之前的 0x20-0x3F 是参数 / 中间字节;SS3 终止字节固定在第三位
47
+ let end = 2;
48
+ if (buf[1] === '[') while (end < buf.length && buf[end] >= ' ' && buf[end] <= '?') end += 1;
49
+ if (end >= buf.length) return; // 不完整前缀:保留余量等下一 chunk 续解
50
+ const final = buf[end];
51
+ const sequence = buf.slice(0, end + 1);
52
+ buf = buf.slice(end + 1);
53
+ // 仅无参数的单终止字节序列映射方向键 / Home / End;其余(带参数修饰、未识别)→ unknown,不误动光标
54
+ const name = end === 2 ? SEQUENCE_KEYS[final] : undefined;
55
+ emit({ name: name ?? 'unknown', sequence });
56
+ continue;
57
+ }
58
+ // ESC 后跟其他字符:整段按未知序列交付(不崩、不误动光标)
59
+ const sequence = buf.slice(0, 2);
60
+ buf = buf.slice(2);
61
+ emit({ name: 'unknown', sequence });
62
+ continue;
63
+ }
64
+ const ch = buf[0];
65
+ buf = buf.slice(1);
66
+ const code = ch.charCodeAt(0);
67
+ if (code === 13 || code === 10) emit({ name: 'return', sequence: ch }); // \r \n → 回车
68
+ else if (code === 9) emit({ name: 'tab', sequence: ch }); // \t
69
+ else if (code === 127 || code === 8) emit({ name: 'backspace', sequence: ch }); // \x7f \x08
70
+ else if (code >= 1 && code <= 26) emit({ name: String.fromCharCode(code + 96), ctrl: true, sequence: ch }); // \x01-\x1a → ctrl+小写字母(含 \x03 → Ctrl+C)
71
+ else if (ch === ' ') emit({ name: 'space', sequence: ch });
72
+ else emit({ name: ch, sequence: ch }); // 字母 / 数字 / 普通字符逐字符;粘贴多字符按序逐个
73
+ }
74
+ };
75
+ const onData = (chunk) => {
76
+ if (escTimer !== null) {
77
+ clearTimeout(escTimer); // 后续数据到达:取消孤立 ESC 判定,按序列续解
78
+ escTimer = null;
79
+ }
80
+ const text = decoder.write(chunk);
81
+ if (process.env.FLOW_KIT_KEY_DEBUG) console.error('[flow-kit key] raw:', JSON.stringify(text));
82
+ buf += text;
83
+ parse();
84
+ };
85
+ const detach = () => {
86
+ if (escTimer !== null) {
87
+ clearTimeout(escTimer); // restore 时清计时,避免挂着的 timer 悬住事件循环
88
+ escTimer = null;
89
+ }
90
+ input.removeListener('data', onData);
91
+ };
92
+ input.on('data', onData);
93
+ return detach;
94
+ }
95
+
96
+ export function createScreen({ input, output }) {
97
+ const state = { interactive: false, last: 0, detachKeyDecoder: null };
98
+ const renderLines = (lines) => {
99
+ let out = state.last > 0 ? `\x1b[${state.last}A` : '';
100
+ for (const line of lines) out += `\r\x1b[2K${line}\n`;
101
+ for (let i = lines.length; i < state.last; i += 1) out += '\r\x1b[2K\n';
102
+ if (state.last > lines.length) out += `\x1b[${state.last - lines.length}A`;
103
+ state.last = lines.length;
104
+ output.write(out);
105
+ };
106
+ const screen = {
107
+ input,
108
+ output,
109
+ // interactive 是交互态唯一事实源:setup 探测 raw mode 可用性后置位
110
+ get interactive() {
111
+ return state.interactive;
112
+ },
113
+ setup() {
114
+ try {
115
+ input.setRawMode(true);
116
+ state.interactive = true;
117
+ } catch {
118
+ state.interactive = false;
119
+ }
120
+ if (state.interactive) {
121
+ // 交互模式挂自研按键解码器(弃用 readline.emitKeypressEvents,理由见文件头);重复 setup 不重复挂
122
+ if (state.detachKeyDecoder === null) state.detachKeyDecoder = attachKeyDecoder(input);
123
+ // 交互模式隐藏光标,restore 配对显示,消除重绘闪烁
124
+ output.write('\x1b[?25l');
125
+ }
126
+ },
127
+ render: renderLines,
128
+ clear: () => renderLines([]),
129
+ // 重置渲染原点后写原始输出:后续 render 从当前光标处新起一块,不覆盖已有内容
130
+ print(str) {
131
+ state.last = 0;
132
+ output.write(str);
133
+ },
134
+ restore() {
135
+ if (state.interactive) output.write('\x1b[?25h');
136
+ if (state.detachKeyDecoder !== null) {
137
+ state.detachKeyDecoder(); // 摘 data 监听并清孤立 ESC 计时
138
+ state.detachKeyDecoder = null;
139
+ }
140
+ try {
141
+ input.setRawMode(false);
142
+ } catch {
143
+ /* 非 TTY 无 raw mode */
144
+ }
145
+ },
146
+ };
147
+ return screen;
148
+ }
149
+
150
+ // 交互式单问:渲染 box → keypress 驱动 widget → 完成清掉区块返回 value;Ctrl+C / EOF 抛 QuitError
151
+ export function runPrompt(screen, { title, question, hint, widget }) {
152
+ const draw = () => screen.render(box(title, [cyan(`◆ ${question}`), '', ...widget.view(), '', dim(hint)]));
153
+ draw();
154
+ return new Promise((resolve, reject) => {
155
+ const input = screen.input;
156
+ // keypress 与 close 监听成对清理,避免多问后监听器堆积
157
+ const cleanup = () => {
158
+ input.removeListener('keypress', onKey);
159
+ input.removeListener('close', onClose);
160
+ };
161
+ const onKey = (key) => {
162
+ // readline 对不完整/分片到达的转义序列会 emit ('keypress', undefined),静默忽略
163
+ if (!key) return;
164
+ if (key.ctrl && key.name === 'c') {
165
+ cleanup();
166
+ reject(new QuitError());
167
+ return;
168
+ }
169
+ const done = widget.onKey(key);
170
+ if (done) {
171
+ cleanup();
172
+ screen.clear();
173
+ resolve(done.value);
174
+ return;
175
+ }
176
+ draw();
177
+ };
178
+ // stdin 关闭(EOF):与降级路径同语义,按 Ctrl+C 干净退出
179
+ const onClose = () => {
180
+ cleanup();
181
+ reject(new QuitError());
182
+ };
183
+ input.on('keypress', onKey);
184
+ input.on('close', onClose);
185
+ });
186
+ }
187
+
188
+ // 降级读行器:整个会话共用一个 readline interface + 行队列,
189
+ // 管道一次性到达的行按序消费不丢;EOF(stdin 关闭)resolve null,保证任意一步干净退出
190
+ export function createLineReader(input, output) {
191
+ const queue = [];
192
+ let pending = null;
193
+ let eof = false;
194
+ const rl = readline.createInterface({ input, output });
195
+ rl.on('line', (line) => {
196
+ if (pending) {
197
+ const resolve = pending;
198
+ pending = null;
199
+ resolve(line);
200
+ } else {
201
+ queue.push(line);
202
+ }
203
+ });
204
+ rl.on('close', () => {
205
+ eof = true;
206
+ if (pending) {
207
+ const resolve = pending;
208
+ pending = null;
209
+ resolve(null);
210
+ }
211
+ });
212
+ return () => new Promise((resolve) => {
213
+ if (queue.length) return resolve(queue.shift());
214
+ if (eof) return resolve(null);
215
+ pending = resolve;
216
+ });
217
+ }