arona-agent 1.0.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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/assets/blue-archive/arona/spine/arona_spr.atlas.txt +1168 -0
  4. package/assets/blue-archive/arona/spine/arona_spr.png +0 -0
  5. package/assets/blue-archive/arona/spine/arona_spr.skel +0 -0
  6. package/assets/blue-archive/arona/voice.mp3 +0 -0
  7. package/assets/blue-archive/hoshino/hoshino_spr.atlas.txt +174 -0
  8. package/assets/blue-archive/hoshino/hoshino_spr.json +15385 -0
  9. package/assets/blue-archive/hoshino/hoshino_spr.png +0 -0
  10. package/assets/blue-archive/hoshino/hoshino_spr.skel +0 -0
  11. package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.atlas.txt +111 -0
  12. package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.png +0 -0
  13. package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.skel +0 -0
  14. package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.atlas.txt +251 -0
  15. package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.png +0 -0
  16. package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.skel +0 -0
  17. package/assets/blue-archive/hoshino/voice.mp3 +0 -0
  18. package/assets/blue-archive/plana/spine/plana_spr.atlas.txt +1266 -0
  19. package/assets/blue-archive/plana/spine/plana_spr.png +0 -0
  20. package/assets/blue-archive/plana/spine/plana_spr.skel +0 -0
  21. package/assets/blue-archive/plana/voice.mp3 +0 -0
  22. package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.atlas.txt +97 -0
  23. package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.png +0 -0
  24. package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.skel +0 -0
  25. package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.atlas.txt +174 -0
  26. package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.png +0 -0
  27. package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.skel +0 -0
  28. package/assets/blue-archive/shiroko/shiroko_spr.atlas.txt +167 -0
  29. package/assets/blue-archive/shiroko/shiroko_spr.json +10702 -0
  30. package/assets/blue-archive/shiroko/shiroko_spr.png +0 -0
  31. package/assets/blue-archive/shiroko/shiroko_spr.skel +0 -0
  32. package/assets/blue-archive/shiroko/voice.mp3 +0 -0
  33. package/bin/arona.mjs +43 -0
  34. package/package.json +37 -0
  35. package/pet/agents.cjs +197 -0
  36. package/pet/main.cjs +462 -0
  37. package/pet/preload.cjs +21 -0
  38. package/pet/renderer/fx.html +30 -0
  39. package/pet/renderer/fx.js +41 -0
  40. package/pet/renderer/gallery.html +18 -0
  41. package/pet/renderer/gallery.js +31 -0
  42. package/pet/renderer/index.html +18 -0
  43. package/pet/renderer/renderer.js +382 -0
  44. package/pet/renderer/spine_layer.js +609 -0
  45. package/pet/renderer/spinetest.html +24 -0
  46. package/pet/renderer/spinetest.js +295 -0
  47. package/pet/renderer/style.css +63 -0
  48. package/pet/tools/gallery_capture.cjs +267 -0
  49. package/pet/tools/gen_sway.cjs +170 -0
  50. package/pet/tools/meshify_region.cjs +105 -0
  51. package/pet/tools/skel_inspect.cjs +112 -0
  52. package/pet/tools/skel_to_json.cjs +410 -0
  53. package/pet/tools/spine_node.cjs +69 -0
  54. package/pet/tools/visual_test.cjs +262 -0
  55. package/pet/vendor/spine/spine-canvas.js +8472 -0
  56. package/pet/vendor/spine/spine-webgl.js +10839 -0
  57. package/python/__pycache__/_i18n.cpython-313.pyc +0 -0
  58. package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
  59. package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
  60. package/python/__pycache__/hotkey.cpython-313.pyc +0 -0
  61. package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
  62. package/python/__pycache__/stt.cpython-313.pyc +0 -0
  63. package/python/__pycache__/stt.cpython-314.pyc +0 -0
  64. package/python/__pycache__/tts.cpython-313.pyc +0 -0
  65. package/python/__pycache__/tts.cpython-314.pyc +0 -0
  66. package/python/__pycache__/tts_stream.cpython-313.pyc +0 -0
  67. package/python/__pycache__/tts_stream.cpython-314.pyc +0 -0
  68. package/python/__pycache__/voice_clone.cpython-314.pyc +0 -0
  69. package/python/_i18n.py +18 -0
  70. package/python/computer_use.py +190 -0
  71. package/python/hotkey.py +196 -0
  72. package/python/stt.py +240 -0
  73. package/python/tts_say.py +177 -0
  74. package/python/voice_clone.py +98 -0
  75. package/requirements.txt +6 -0
  76. package/src/agent.ts +791 -0
  77. package/src/agent_registry.ts +109 -0
  78. package/src/commands.ts +641 -0
  79. package/src/config.ts +245 -0
  80. package/src/in_memory_credentials.ts +61 -0
  81. package/src/index.ts +78 -0
  82. package/src/locale.ts +131 -0
  83. package/src/logo.ts +120 -0
  84. package/src/mcp.ts +192 -0
  85. package/src/memory.ts +303 -0
  86. package/src/pet.ts +346 -0
  87. package/src/renderer.ts +350 -0
  88. package/src/repl.ts +902 -0
  89. package/src/setup.ts +371 -0
  90. package/src/skills.ts +96 -0
  91. package/src/slash_menu.ts +371 -0
  92. package/src/slash_registry.ts +53 -0
  93. package/src/speaker_context.ts +41 -0
  94. package/src/text_split.ts +79 -0
  95. package/src/tools/computer_use.ts +142 -0
  96. package/src/tools/emotion_tool.ts +55 -0
  97. package/src/tools/keep_silent_tool.ts +26 -0
  98. package/src/tools/memory_tool.ts +24 -0
  99. package/src/tools/skill_tools.ts +100 -0
  100. package/src/tools/tavily_tools.ts +431 -0
  101. package/src/tools/voice_tools.ts +28 -0
  102. package/src/tts_stream.ts +260 -0
  103. package/src/tui_select.ts +146 -0
  104. package/src/undo.ts +451 -0
  105. package/src/utils/python.ts +211 -0
  106. package/src/utils/spawn.ts +56 -0
  107. package/src/voice.ts +83 -0
  108. package/src/voice_cli.ts +198 -0
  109. package/src/voices.ts +135 -0
@@ -0,0 +1,260 @@
1
+ import type { ChildProcessWithoutNullStreams } from "child_process";
2
+ import { join } from "path";
3
+ import { PYTHON_DIR, config, verbose } from "./config.ts";
4
+ import { t } from "./locale.ts";
5
+ import { spawnCompat, stripProxyEnv } from "./utils/spawn.ts";
6
+ import { stripMarkdown } from "./voice.ts";
7
+ import { getMainAgent, type AgentId } from "./agent_registry.ts";
8
+ import { getVoiceId } from "./voices.ts";
9
+ import { splitStreamedText, countTextUnits } from "./text_split.ts";
10
+
11
+ /**
12
+ * 非流式 TTS 管道(阿里云百炼"非实时语音合成",整句一次 HTTP 合成)。
13
+ *
14
+ * 每句话 spawn 一次性 Python 进程 python/tts_say.py,通过 stdin/stdout JSON 行通信:
15
+ * 发送:{"text":"...","voice":"<id>"}(单行,进程随即退出)
16
+ * 接收:{"event":"ready"} / {"event":"play_start"} / {"event":"play_end"} / {"event":"error","message":...}
17
+ *
18
+ * 行为约定:
19
+ * - 只有回合最后一个 assistant message 的文本走 TTS:renderer 累积 text_delta,
20
+ * agent_end 时把最后一段完整文本传给 endTurn(text) 一次性处理(中间穿插工具调用的
21
+ * 过程性发言不朗读)——避免被 change_emotion 等工具调用切成多段的句子被拆读
22
+ * - 整段字数判断:countTextUnits(整段) >= MAX_TURN_LEN 则整段跳过不朗读(长回复静音)
23
+ * - 围栏代码块(```...```)整块剔除;句内 markdown 成句后剔除
24
+ * - 句子进入严格串行队列:合成(HTTP+下载)→ 播放 → 下一句
25
+ * - 音色每句动态取 getVoiceId(currentAgent),setVoice 仅切换当前角色、无需重启进程
26
+ * - 覆盖主 + 子 Agent:同一实例通过 setVoice 切角色
27
+ */
28
+ // 整段字数阈值(countTextUnits 口径):>= 50 的回复不朗读
29
+ const MAX_SENTENCE_LEN = 50;
30
+ const TTS_FORCE_SPLIT_LEN = 9999; // 仅标点切句、不强切
31
+
32
+ /** 剔除整段文本中的 ``` 代码围栏(文本已完整,无需跨 delta 状态机)。 */
33
+ function stripCodeBlocks(s: string): string {
34
+ // 先删成对围栏块,再清掉残留的单个 ```(未闭合/游离标记)
35
+ return s.replace(/```[\s\S]*?```/g, " ").replace(/```/g, " ");
36
+ }
37
+
38
+ export class TtsStream {
39
+ private proc: ChildProcessWithoutNullStreams | null = null;
40
+ private pendingCount = 0; // 活跃播放段数(play_start +1 / play_end -1)
41
+ private queue: string[] = []; // 待合成句子队列
42
+ private busy = false; // 当前是否有句子在合成/播放
43
+ private shuttingDown = false;
44
+ private _killRequested = false; // 本次是否因 cancel 主动杀进程(区分正常结束与打断)
45
+ private currentAgent: AgentId = getMainAgent();
46
+
47
+ constructor(
48
+ private isEnabledFor: (agent: AgentId) => boolean,
49
+ private onIdle?: () => void,
50
+ ) {}
51
+
52
+ /** 当前是否有未播放完的合成段(供 repl 判断 pet.reset 时机) */
53
+ get isPending(): boolean {
54
+ return this.pendingCount > 0 || this.busy || this.queue.length > 0;
55
+ }
56
+
57
+ /** 当前 TTS 音色所属角色 */
58
+ get currentAgentId(): AgentId {
59
+ return this.currentAgent;
60
+ }
61
+
62
+ /** 切换当前朗读角色音色(非流式下每句 spawn 时动态取 voice,无需重启进程)。 */
63
+ setVoice(agentId: AgentId): void {
64
+ if (this.currentAgent !== agentId) {
65
+ this.currentAgent = agentId;
66
+ if (verbose) console.error(`[tts] setVoice ${agentId}`);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 回合结束:处理最后一段回复文本(由 renderer 在 agent_end 时调用)。
72
+ * - 空文本(keep_silent / 无最终发言)→ 不朗读
73
+ * - 整段字数 >= MAX_SENTENCE_LEN → 整段跳过(长回复静音)
74
+ * - 否则切句入队,串行合成播放
75
+ */
76
+ endTurn(text: string): void {
77
+ if (!this.isEnabledFor(this.currentAgent)) return;
78
+ if (!text || !text.trim()) {
79
+ if (verbose) console.error("[tts] endTurn: no final text, skip");
80
+ return;
81
+ }
82
+ const full = stripCodeBlocks(text).trim();
83
+ if (!full) return;
84
+ const units = countTextUnits(stripMarkdown(full));
85
+ if (units === 0 || units >= MAX_SENTENCE_LEN) {
86
+ if (verbose) console.error(`[tts] skip whole turn units=${units} "${full.slice(0, 40)}"`);
87
+ return;
88
+ }
89
+ const { sentences, rest } = splitStreamedText(full, TTS_FORCE_SPLIT_LEN);
90
+ for (const s of sentences) this.emitSentence(s);
91
+ const tail = rest.trim();
92
+ if (tail) this.emitSentence(tail);
93
+ void this.drain();
94
+ }
95
+
96
+ /** 打断当前合成与队列(新输入 / Esc / Ctrl+C / STT 触发时调用)。 */
97
+ cancel(): void {
98
+ this.queue = [];
99
+ this.pendingCount = 0;
100
+ this.killProc();
101
+ if (verbose) console.error("[tts] cancel (queue cleared)");
102
+ }
103
+
104
+ /** 切换角色后清空残余(非流式音色动态取,无需杀进程重拉)。 */
105
+ restartVoice(): void {
106
+ this.cancel();
107
+ }
108
+
109
+ /** 退出时停止当前合成进程 */
110
+ shutdown(): void {
111
+ this.shuttingDown = true;
112
+ this.cancel();
113
+ }
114
+
115
+ // ------------------------------------------------------------
116
+ // 内部
117
+ // ------------------------------------------------------------
118
+
119
+ /**
120
+ * 句级过滤:剔除句内 markdown 后按字数判断。字数口径 = 中文每字 1、英文单词 1、
121
+ * 符号不计(countTextUnits);字数为 0(纯符号)的句子不朗读。
122
+ * (整段已 < MAX_SENTENCE_LEN,单句必然 < 阈值,此处过滤为纯防御)
123
+ */
124
+ private emitSentence(sentence: string): void {
125
+ const clean = stripMarkdown(sentence).trim();
126
+ if (!clean) return;
127
+ const units = countTextUnits(clean);
128
+ if (units === 0 || units >= MAX_SENTENCE_LEN) {
129
+ if (verbose) console.error(`[tts] skip sentence units=${units} "${clean.slice(0, 40)}"`);
130
+ return;
131
+ }
132
+ if (verbose) console.error(`[tts] queue push units=${units} "${clean.slice(0, 40)}"`);
133
+ this.queue.push(clean);
134
+ void this.drain();
135
+ }
136
+
137
+ /** 串行排空队列:合成 + 播放一句再下一句。全部播完且未被打断时触发 onIdle。 */
138
+ private async drain(): Promise<void> {
139
+ if (this.busy || this.shuttingDown) return;
140
+ if (this.queue.length === 0) return;
141
+ this.busy = true;
142
+ let aborted = false;
143
+ try {
144
+ while (this.queue.length > 0 && !this.shuttingDown) {
145
+ const sentence = this.queue.shift()!;
146
+ if (verbose) console.error(`[tts] speakOne start "${sentence.slice(0, 40)}"`);
147
+ const ok = await this.speakOne(sentence);
148
+ if (verbose) console.error(`[tts] speakOne end ok=${ok} remaining=${this.queue.length}`);
149
+ if (!ok) {
150
+ aborted = true;
151
+ break;
152
+ }
153
+ }
154
+ } finally {
155
+ this.busy = false;
156
+ }
157
+ if (!aborted && !this.shuttingDown && this.pendingCount === 0) {
158
+ this.onIdle?.();
159
+ }
160
+ }
161
+
162
+ /**
163
+ * 合成并播放一句话:spawn 一次性 python 进程,等待其播完(play_end)或退出。
164
+ * @returns true = 正常结束(含合成失败,继续下一句);false = 被 cancel 打断
165
+ */
166
+ private speakOne(text: string): Promise<boolean> {
167
+ return new Promise((resolve) => {
168
+ this._killRequested = false;
169
+ let settled = false;
170
+ const doResolve = (val: boolean) => {
171
+ if (settled) return;
172
+ settled = true;
173
+ resolve(val);
174
+ };
175
+ let stdoutBuffer = "";
176
+ const proc = spawnCompat(config.pythonPath, ["-u", join(PYTHON_DIR, "tts_say.py")], {
177
+ env: stripProxyEnv({
178
+ ...process.env,
179
+ PYTHONUTF8: "1",
180
+ QWEN_WORKSPACE_ID: config.workspaceId,
181
+ QWEN_TTS_API_KEY: config.ttsApiKey,
182
+ QWEN_TTS_MODEL: config.ttsModel,
183
+ QWEN_TTS_VOICE: getVoiceId(this.currentAgent),
184
+ }),
185
+ stdio: ["pipe", "pipe", "pipe"],
186
+ });
187
+ this.proc = proc;
188
+
189
+ proc.stdout.on("data", (data) => {
190
+ stdoutBuffer += data.toString();
191
+ const lines = stdoutBuffer.split("\n");
192
+ stdoutBuffer = lines.pop() || "";
193
+ for (const line of lines) {
194
+ const trimmed = line.trim();
195
+ if (!trimmed) continue;
196
+ let evt: { event?: string; message?: string };
197
+ try {
198
+ evt = JSON.parse(trimmed);
199
+ } catch {
200
+ continue;
201
+ }
202
+ switch (evt.event) {
203
+ case "play_start":
204
+ this.pendingCount++;
205
+ if (verbose) console.error(`[tts] play_start pending=${this.pendingCount}`);
206
+ break;
207
+ case "play_end":
208
+ if (this.pendingCount > 0) this.pendingCount--;
209
+ if (verbose) console.error(`[tts] play_end pending=${this.pendingCount}`);
210
+ doResolve(true); // 播放完成
211
+ break;
212
+ case "error":
213
+ console.warn(t(`TTS: ${evt.message ?? "unknown error"}`, `TTS: ${evt.message ?? "unknown error"}`));
214
+ if (verbose) console.error(`[tts] error: ${evt.message}`);
215
+ // 合成失败不中断队列,靠 close 再 resolve(true) 继续下一句
216
+ break;
217
+ default:
218
+ break;
219
+ }
220
+ }
221
+ });
222
+
223
+ proc.stderr.on("data", (data) => {
224
+ if (!verbose) return;
225
+ const msg = data.toString().trim();
226
+ if (msg) console.error(`[python:tts_say]`, msg);
227
+ });
228
+
229
+ proc.on("close", () => {
230
+ if (this.proc === proc) this.proc = null;
231
+ // 若已通过 play_end 结算,则 close 不再改写结果;否则按是否被 kill 决定
232
+ doResolve(!this._killRequested);
233
+ });
234
+ proc.on("error", () => {
235
+ if (this.proc === proc) this.proc = null;
236
+ doResolve(!this._killRequested);
237
+ });
238
+
239
+ try {
240
+ proc.stdin.write(JSON.stringify({ text, voice: getVoiceId(this.currentAgent) }) + "\n");
241
+ proc.stdin.end();
242
+ } catch {
243
+ // 忽略
244
+ }
245
+ });
246
+ }
247
+
248
+ private killProc(): void {
249
+ this._killRequested = true;
250
+ const proc = this.proc;
251
+ this.proc = null;
252
+ if (proc && !proc.killed) {
253
+ try {
254
+ proc.kill("SIGTERM");
255
+ } catch {
256
+ // 忽略
257
+ }
258
+ }
259
+ }
260
+ }
@@ -0,0 +1,146 @@
1
+ // 通用多选 TUI 选择器(raw-mode stdin)。
2
+ // 用于 setup 与 arona voice add 的角色音色选择:↑/↓ 移动、空格切换 [*]/[ ]、回车确认、Esc/q/Ctrl+C 取消。
3
+ // 复用 commands.ts 的可见宽度计算与行截断思路,避免 CJK 折行导致重绘叠字。
4
+
5
+ import chalk from "chalk";
6
+
7
+ export interface SelectOption {
8
+ id: string;
9
+ label: string;
10
+ /** 锁定:强制显示 [*],空格不可切换(已有音色的角色)。 */
11
+ locked?: boolean;
12
+ }
13
+
14
+ /** 计算一个字符串去除 ANSI 转义后的可见宽度(CJK/全角算 2,控制符算 0)。 */
15
+ function visibleWidth(s: string): number {
16
+ const stripped = s.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|[0-9])/g, "");
17
+ let w = 0;
18
+ for (const ch of stripped) {
19
+ const code = ch.codePointAt(0)!;
20
+ if (code >= 0x1100 && (
21
+ code <= 0x115f || (code >= 0x2e80 && code <= 0x303e) ||
22
+ (code >= 0x3040 && code <= 0x33bf) || (code >= 0x3400 && code <= 0x4dbf) ||
23
+ (code >= 0x4e00 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7af) ||
24
+ (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe6f) ||
25
+ (code >= 0xff01 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6)
26
+ )) w += 2;
27
+ else if (code >= 0x20) w += 1;
28
+ }
29
+ return w;
30
+ }
31
+
32
+ /** 截断字符串到可见宽度(保留 ANSI 颜色转义,超出加 …)。 */
33
+ function truncateStyled(text: string, maxW: number, style: (s: string) => string): string {
34
+ if (visibleWidth(text) <= maxW) return style(text);
35
+ let res = "";
36
+ let w = 0;
37
+ for (const ch of text) {
38
+ const code = ch.codePointAt(0)!;
39
+ const cw = (code >= 0x1100 && (
40
+ code <= 0x115f || (code >= 0x2e80 && code <= 0x303e) ||
41
+ (code >= 0x3040 && code <= 0x33bf) || (code >= 0x3400 && code <= 0x4dbf) ||
42
+ (code >= 0x4e00 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7af) ||
43
+ (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe6f) ||
44
+ (code >= 0xff01 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6)
45
+ )) ? 2 : 1;
46
+ if (w + cw > maxW - 1) break;
47
+ res += ch;
48
+ w += cw;
49
+ }
50
+ return style(res + "…");
51
+ }
52
+
53
+ /**
54
+ * 多选 TUI。返回选中的 id 集合;用户取消(Esc/q/Ctrl+C)返回 null。
55
+ *
56
+ * 交互:↑/↓ 循环移动光标,空格切换 [*]/[ ],回车确认。
57
+ * 调用方需保证进入前 stdin 未被 readline 占用(setup 在调用前 rl.close())。
58
+ */
59
+ export async function multiSelect(
60
+ title: string,
61
+ options: SelectOption[],
62
+ initiallySelected: Set<string>,
63
+ hint?: string,
64
+ ): Promise<Set<string> | null> {
65
+ if (options.length === 0) return new Set<string>();
66
+
67
+ const wasRaw = process.stdin.isRaw ?? false;
68
+ if (process.stdin.isTTY) {
69
+ process.stdin.setRawMode(true);
70
+ }
71
+ process.stdin.resume();
72
+ process.stdin.setEncoding("utf8");
73
+
74
+ const selected = new Set<string>(initiallySelected);
75
+ let cursor = 0;
76
+ let drawnScreenLines = 0;
77
+ const cols = process.stdout.columns ?? 80;
78
+
79
+ const render = () => {
80
+ if (drawnScreenLines > 0) {
81
+ process.stdout.write("\x1b[" + drawnScreenLines + "A");
82
+ }
83
+ process.stdout.write("\r\x1b[0J");
84
+ const maxW = cols - 1;
85
+ const out: string[] = [];
86
+ // title / hint 也必须截断到终端宽度:否则超宽折行 → 实际屏幕行数 > 逻辑行数,
87
+ // drawnScreenLines 低估导致下一次 \x1b[N A 上移不足、上一帧残留累积(英文长标题复现)
88
+ out.push(truncateStyled(title, maxW, (x) => chalk.bold.cyan(x)));
89
+ options.forEach((o, i) => {
90
+ const checked = o.locked || selected.has(o.id);
91
+ const marker = checked ? "[*]" : "[ ]";
92
+ const text = `${marker} ${o.label}`;
93
+ const styled = i === cursor
94
+ ? truncateStyled(text, maxW, (x) => chalk.bold.cyan(x))
95
+ : truncateStyled(text, maxW, (x) => x);
96
+ out.push(styled);
97
+ });
98
+ if (hint) out.push(truncateStyled(hint, maxW, (x) => chalk.cyan(x)));
99
+ process.stdout.write(out.join("\r\n") + "\r\n");
100
+ drawnScreenLines = out.length;
101
+ };
102
+
103
+ render();
104
+
105
+ let resolveFn: (v: Set<string> | null) => void = () => {};
106
+ const onData = (key: string) => {
107
+ if (key === "\x1b[A") {
108
+ cursor = (cursor - 1 + options.length) % options.length;
109
+ render();
110
+ } else if (key === "\x1b[B") {
111
+ cursor = (cursor + 1) % options.length;
112
+ render();
113
+ } else if (key === " ") {
114
+ const o = options[cursor];
115
+ if (o.locked) return; // 锁定项:空格无效,保持 [*]
116
+ if (selected.has(o.id)) selected.delete(o.id);
117
+ else selected.add(o.id);
118
+ render();
119
+ } else if (key === "\r" || key === "\n") {
120
+ cleanup();
121
+ resolveFn(selected);
122
+ } else if (key === "\x1b" || key === "\x1b\x1b" || key === "q" || key === "\x03") {
123
+ cleanup();
124
+ resolveFn(null);
125
+ }
126
+ };
127
+
128
+ const cleanup = () => {
129
+ process.stdin.removeListener("data", onData);
130
+ // 恢复进入前的 raw mode 状态(setup 已 rl.close()、voice_cli 无 rl,均安全)
131
+ if (process.stdin.isTTY) {
132
+ try {
133
+ process.stdin.setRawMode(wasRaw);
134
+ } catch {
135
+ // 忽略
136
+ }
137
+ }
138
+ // pause stdin:resume() 后 stdin 保持 flowing,事件循环被 stdin 引用挂住,
139
+ // 进程不会退出(按 Esc/回车取消后只能 Ctrl+C 强退)。本 TUI 仅用于
140
+ // setup / voice add 的独立进程场景(REPL 内联选择器才需要保持 resume)。
141
+ process.stdin.pause();
142
+ };
143
+
144
+ process.stdin.on("data", onData);
145
+ return new Promise<Set<string> | null>((resolve) => { resolveFn = resolve; });
146
+ }