skyloom 1.14.6 → 1.15.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 (157) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/publish.yml +74 -0
  3. package/CONVERSION_PLAN.md +191 -191
  4. package/README.md +523 -220
  5. package/config/default.yaml +46 -43
  6. package/config/models.yaml +928 -155
  7. package/config/providers.yaml +109 -6
  8. package/dist/agents/snow.d.ts +2 -0
  9. package/dist/agents/snow.d.ts.map +1 -1
  10. package/dist/agents/snow.js +36 -5
  11. package/dist/agents/snow.js.map +1 -1
  12. package/dist/cli/loom_chat.d.ts.map +1 -1
  13. package/dist/cli/loom_chat.js +207 -1
  14. package/dist/cli/loom_chat.js.map +1 -1
  15. package/dist/cli/main.js +190 -40
  16. package/dist/cli/main.js.map +1 -1
  17. package/dist/cli/tui.d.ts.map +1 -1
  18. package/dist/cli/tui.js +6 -31
  19. package/dist/cli/tui.js.map +1 -1
  20. package/dist/core/agent.d.ts +6 -4
  21. package/dist/core/agent.d.ts.map +1 -1
  22. package/dist/core/agent.js +61 -20
  23. package/dist/core/agent.js.map +1 -1
  24. package/dist/core/catalog.d.ts.map +1 -1
  25. package/dist/core/catalog.js +30 -9
  26. package/dist/core/catalog.js.map +1 -1
  27. package/dist/core/commands.d.ts +110 -0
  28. package/dist/core/commands.d.ts.map +1 -0
  29. package/dist/core/commands.js +633 -0
  30. package/dist/core/commands.js.map +1 -0
  31. package/dist/core/concurrency.d.ts +38 -0
  32. package/dist/core/concurrency.d.ts.map +1 -0
  33. package/dist/core/concurrency.js +65 -0
  34. package/dist/core/concurrency.js.map +1 -0
  35. package/dist/core/factory.js +16 -16
  36. package/dist/core/file_checkpoint.d.ts +9 -0
  37. package/dist/core/file_checkpoint.d.ts.map +1 -1
  38. package/dist/core/file_checkpoint.js +33 -1
  39. package/dist/core/file_checkpoint.js.map +1 -1
  40. package/dist/core/llm.d.ts.map +1 -1
  41. package/dist/core/llm.js +66 -13
  42. package/dist/core/llm.js.map +1 -1
  43. package/dist/core/memory.js +51 -51
  44. package/dist/core/schemas.d.ts +16 -0
  45. package/dist/core/schemas.d.ts.map +1 -1
  46. package/dist/core/schemas.js +32 -0
  47. package/dist/core/schemas.js.map +1 -1
  48. package/dist/core/security.d.ts.map +1 -1
  49. package/dist/core/security.js +27 -0
  50. package/dist/core/security.js.map +1 -1
  51. package/dist/core/skymd.js +14 -14
  52. package/dist/core/trace.d.ts +105 -0
  53. package/dist/core/trace.d.ts.map +1 -0
  54. package/dist/core/trace.js +213 -0
  55. package/dist/core/trace.js.map +1 -0
  56. package/dist/tools/builtin.d.ts +2 -6
  57. package/dist/tools/builtin.d.ts.map +1 -1
  58. package/dist/tools/builtin.js +180 -125
  59. package/dist/tools/builtin.js.map +1 -1
  60. package/dist/tools/extra.d.ts +13 -0
  61. package/dist/tools/extra.d.ts.map +1 -0
  62. package/dist/tools/extra.js +827 -0
  63. package/dist/tools/extra.js.map +1 -0
  64. package/dist/tools/guards.d.ts +12 -0
  65. package/dist/tools/guards.d.ts.map +1 -0
  66. package/dist/tools/guards.js +143 -0
  67. package/dist/tools/guards.js.map +1 -0
  68. package/dist/tools/model_tool.d.ts.map +1 -1
  69. package/dist/tools/model_tool.js +24 -4
  70. package/dist/tools/model_tool.js.map +1 -1
  71. package/dist/web/markdown.d.ts +32 -0
  72. package/dist/web/markdown.d.ts.map +1 -0
  73. package/dist/web/markdown.js +202 -0
  74. package/dist/web/markdown.js.map +1 -0
  75. package/dist/web/server.d.ts +4 -0
  76. package/dist/web/server.d.ts.map +1 -1
  77. package/dist/web/server.js +14 -582
  78. package/dist/web/server.js.map +1 -1
  79. package/dist/web/ui.d.ts +31 -0
  80. package/dist/web/ui.d.ts.map +1 -0
  81. package/dist/web/ui.js +1009 -0
  82. package/dist/web/ui.js.map +1 -0
  83. package/docs/AESTHETIC_DESIGN.md +152 -152
  84. package/docs/OPTIMIZATION_PLAN.md +178 -178
  85. package/package.json +68 -68
  86. package/src/agents/snow.ts +38 -5
  87. package/src/cli/commands_md.ts +112 -112
  88. package/src/cli/input_macros.ts +83 -83
  89. package/src/cli/loom.ts +1041 -1041
  90. package/src/cli/loom_chat.ts +772 -603
  91. package/src/cli/main.ts +853 -723
  92. package/src/cli/tui.ts +264 -289
  93. package/src/core/agent/guard.ts +133 -133
  94. package/src/core/agent/task.ts +100 -100
  95. package/src/core/agent.ts +1630 -1590
  96. package/src/core/agent_helpers.ts +500 -500
  97. package/src/core/bus.ts +221 -221
  98. package/src/core/cache.ts +153 -153
  99. package/src/core/catalog.ts +199 -178
  100. package/src/core/circuit_breaker.ts +119 -119
  101. package/src/core/commands.ts +704 -0
  102. package/src/core/concurrency.ts +73 -0
  103. package/src/core/config.ts +365 -365
  104. package/src/core/constants.ts +95 -95
  105. package/src/core/factory.ts +656 -656
  106. package/src/core/file_checkpoint.ts +163 -136
  107. package/src/core/hooks.ts +126 -126
  108. package/src/core/llm.ts +972 -915
  109. package/src/core/logger.ts +143 -143
  110. package/src/core/mcp.ts +1001 -1001
  111. package/src/core/memory.ts +1201 -1201
  112. package/src/core/middleware.ts +350 -350
  113. package/src/core/model_config.ts +159 -159
  114. package/src/core/pipelines.ts +424 -424
  115. package/src/core/schemas.ts +319 -282
  116. package/src/core/security.ts +27 -0
  117. package/src/core/semantic.ts +211 -211
  118. package/src/core/skill.ts +384 -384
  119. package/src/core/skymd.ts +143 -143
  120. package/src/core/theme.ts +65 -65
  121. package/src/core/tool.ts +457 -457
  122. package/src/core/trace.ts +236 -0
  123. package/src/core/verify.ts +71 -71
  124. package/src/plugins/loader.ts +91 -91
  125. package/src/skills/loader.ts +75 -75
  126. package/src/tools/builtin.ts +571 -493
  127. package/src/tools/computer.ts +279 -279
  128. package/src/tools/extra.ts +662 -0
  129. package/src/tools/guards.ts +82 -0
  130. package/src/tools/model_tool.ts +93 -74
  131. package/src/tools/todo.ts +76 -76
  132. package/src/web/markdown.ts +193 -0
  133. package/src/web/server.ts +117 -693
  134. package/src/web/ui.ts +949 -0
  135. package/tests/agent.test.ts +211 -159
  136. package/tests/agent_helpers.test.ts +48 -48
  137. package/tests/catalog.test.ts +86 -86
  138. package/tests/checkpoint_commands.test.ts +124 -124
  139. package/tests/claude_compat.test.ts +110 -110
  140. package/tests/commands.test.ts +103 -0
  141. package/tests/concurrency.test.ts +102 -0
  142. package/tests/config.test.ts +41 -41
  143. package/tests/extra_tools.test.ts +212 -0
  144. package/tests/fence_plugin.test.ts +52 -52
  145. package/tests/guard.test.ts +75 -75
  146. package/tests/loom.test.ts +337 -337
  147. package/tests/memory.test.ts +170 -170
  148. package/tests/model_config.test.ts +109 -109
  149. package/tests/skymd.test.ts +146 -146
  150. package/tests/ssrf.test.ts +38 -38
  151. package/tests/structured_retry.test.ts +87 -0
  152. package/tests/task.test.ts +60 -60
  153. package/tests/todo_toolstats.test.ts +94 -94
  154. package/tests/trace.test.ts +128 -0
  155. package/tests/tui.test.ts +67 -67
  156. package/tests/web.test.ts +169 -0
  157. package/tsconfig.json +38 -38
package/src/cli/loom.ts CHANGED
@@ -1,1041 +1,1041 @@
1
- /**
2
- * 天空织机 · 立轴 — the full-screen ink-wash weather-station TUI.
3
- *
4
- * Architecture notes (why this one works where the old full-screen attempt
5
- * failed):
6
- * 1. Streamed text never touches the terminal directly. It lands in a
7
- * virtual block buffer; every frame is composed in memory and a diff
8
- * renderer repaints only the rows that changed. Streaming and animation
9
- * therefore cannot fight over the cursor.
10
- * 2. All width math goes through the CJK-aware helpers in tui.ts, so the
11
- * hand-rolled input editor cannot mangle fullwidth glyphs.
12
- * 3. The animation clock is the single writer: key events and stream events
13
- * only mutate state; the frame timer (and explicit repaint requests)
14
- * flush it.
15
- *
16
- * Layout (画轴 / hanging scroll):
17
- * ┌─ 天空织机 ───────────────────────────── ▣ 霧 ─┐ header + seal
18
- * │ ≋ ❉ ⸽ particles / shuttles │ sky band (2 rows)
19
- * │ ▁▂▃▅▃▂▁▁▂▄▂▁ mountain grows with the session │
20
- * │ ● 霧 fog │ conversation viewport │ rail │ viewport
21
- * │ · 雨 rain │ … │
22
- * ├─ 思忖 ··· ──────────────── model · cost · ctx ─┤ status divider
23
- * │ ≋ ❯ input │ input line
24
- * └─ /help · Tab 补全 · PgUp 翻页 ─────────────────┘
25
- *
26
- * Design rationale: docs/AESTHETIC_DESIGN.md §2.2 (方案三 · 立轴).
27
- */
28
-
29
- import * as readline from "readline";
30
- import chalk from "chalk";
31
- import { agentTheme, AGENT_ORDER, PALETTE } from "../core/theme";
32
- import { charWidth, visualWidth, SLASH_COMMANDS } from "./tui";
33
-
34
- /* ════════════════════════════════════════
35
- ANSI-aware string helpers (pure, tested)
36
- ════════════════════════════════════════ */
37
-
38
- const ESC = "\x1b";
39
- const ANSI_RE = /\x1b\[[0-9;]*m/;
40
-
41
- /** Truncate a styled string to a visual width, keeping ANSI sequences intact. */
42
- export function cutVisual(s: string, maxW: number): string {
43
- let out = "";
44
- let w = 0;
45
- let i = 0;
46
- let cut = false;
47
- while (i < s.length) {
48
- if (s[i] === ESC) {
49
- const m = ANSI_RE.exec(s.slice(i));
50
- if (m && m.index === 0) { out += m[0]; i += m[0].length; continue; }
51
- }
52
- const cp = s.codePointAt(i)!;
53
- const ch = String.fromCodePoint(cp);
54
- const cw = charWidth(cp);
55
- if (w + cw > maxW) { cut = true; break; }
56
- out += ch;
57
- w += cw;
58
- i += ch.length;
59
- }
60
- return cut ? out + "\x1b[0m" : out;
61
- }
62
-
63
- /** Pad a styled string with spaces to an exact visual width (truncates if over). */
64
- export function padAnsi(s: string, w: number): string {
65
- const cutS = visualWidth(s) > w ? cutVisual(s, w) : s;
66
- const diff = w - visualWidth(cutS);
67
- return diff > 0 ? cutS + " ".repeat(diff) : cutS;
68
- }
69
-
70
- /** CJK-aware plain-text word wrap (latin wraps on spaces, CJK per glyph). */
71
- export function wrapPlain(text: string, width: number): string[] {
72
- const lines: string[] = [];
73
- if (width < 4) width = 4;
74
- for (const raw of text.split("\n")) {
75
- let line = "";
76
- let col = 0;
77
- let word = "";
78
- const flushWord = () => {
79
- if (!word) return;
80
- const w = visualWidth(word);
81
- if (col > 0 && col + w > width) { lines.push(line.trimEnd()); line = ""; col = 0; }
82
- // hard-break monster tokens (plain glyph slicing — no ANSI involved)
83
- while (visualWidth(word) > width) {
84
- let head = "", hw = 0, i = 0;
85
- for (const ch of word) {
86
- const cw = charWidth(ch.codePointAt(0)!);
87
- if (hw + cw > width - col) break;
88
- head += ch; hw += cw; i += ch.length;
89
- }
90
- lines.push(line + head);
91
- word = word.slice(i);
92
- line = ""; col = 0;
93
- }
94
- line += word; col += visualWidth(word); word = "";
95
- };
96
- for (const ch of raw) {
97
- const cp = ch.codePointAt(0)!;
98
- if (ch === " " || ch === "\t") {
99
- flushWord();
100
- if (col > 0 && col < width) { line += " "; col += 1; }
101
- continue;
102
- }
103
- if (charWidth(cp) === 2) {
104
- flushWord();
105
- if (col + 2 > width) { lines.push(line.trimEnd()); line = ""; col = 0; }
106
- line += ch; col += 2;
107
- continue;
108
- }
109
- word += ch;
110
- }
111
- flushWord();
112
- lines.push(line);
113
- }
114
- // trim trailing blank produced by terminal newline at very end
115
- while (lines.length > 1 && lines[lines.length - 1] === "" && text.endsWith("\n")) lines.pop();
116
- return lines;
117
- }
118
-
119
- /* ════════════════════════════════════════
120
- Screen — double-buffered diff renderer
121
- ════════════════════════════════════════ */
122
-
123
- export interface OutLike {
124
- columns?: number;
125
- rows?: number;
126
- isTTY?: boolean;
127
- write(s: string): boolean;
128
- }
129
-
130
- /** Repaints only rows whose content changed since the previous frame. */
131
- export class Screen {
132
- private prev: string[] = [];
133
- constructor(private out: OutLike) {}
134
-
135
- /** Force the next flush to repaint everything (resize / resume). */
136
- invalidate() { this.prev = []; }
137
-
138
- flush(rows: string[], cursor: { row: number; col: number } | null) {
139
- let seq = "\x1b[?25l"; // hide cursor while painting
140
- for (let i = 0; i < rows.length; i++) {
141
- if (this.prev[i] !== rows[i]) {
142
- seq += `\x1b[${i + 1};1H\x1b[2K` + rows[i];
143
- }
144
- }
145
- if (this.prev.length > rows.length) {
146
- for (let i = rows.length; i < this.prev.length; i++) seq += `\x1b[${i + 1};1H\x1b[2K`;
147
- }
148
- if (cursor) seq += `\x1b[${cursor.row + 1};${cursor.col + 1}H\x1b[?25h`;
149
- this.out.write(seq);
150
- this.prev = rows.slice();
151
- }
152
- }
153
-
154
- /* ════════════════════════════════════════
155
- Sky band — weather particles & loom shuttles
156
- ════════════════════════════════════════ */
157
-
158
- interface Particle { x: number; y: number; phase: number }
159
-
160
- /** Per-agent weather motion over a w×2 field. drift/fall/glint/float/bead/rise. */
161
- export class SkyField {
162
- particles: Particle[] = [];
163
- private w = 0;
164
- constructor(private readonly h: number = 2) {}
165
-
166
- resize(w: number) {
167
- this.w = Math.max(8, w);
168
- const n = Math.max(3, Math.floor(this.w / 7));
169
- this.particles = Array.from({ length: n }, (_, i) => ({
170
- x: (i * 7.3 + (i * i % 5)) % this.w,
171
- y: (i * 13) % this.h,
172
- phase: (i * 37) % 17,
173
- }));
174
- }
175
-
176
- step(motion: string, tick: number) {
177
- for (const p of this.particles) {
178
- switch (motion) {
179
- case "drift": p.x += 0.45; p.y = (Math.sin((tick + p.phase) / 6) > 0 ? 0 : 1); break;
180
- case "fall": p.y += 0.55; p.x += 0.12; break;
181
- case "glint": /* static, blink via phase at render */ break;
182
- case "float": p.y += 0.28; p.x += Math.sin((tick + p.phase) / 4) * 0.5; break;
183
- case "bead": /* static, brightness breathes */ break;
184
- case "rise": p.y -= 0.3; break;
185
- }
186
- if (p.x >= this.w) p.x -= this.w;
187
- if (p.x < 0) p.x += this.w;
188
- if (p.y >= this.h) p.y -= this.h;
189
- if (p.y < 0) p.y += this.h;
190
- }
191
- }
192
-
193
- /** Render the two sky rows. Shuttles (orchestration) overlay the weather. */
194
- render(
195
- w: number,
196
- motion: string,
197
- symbol: string,
198
- hex: string,
199
- tick: number,
200
- shuttles: { symbol: string; hex: string; x: number; row: number }[],
201
- ): string[] {
202
- if (w !== this.w) this.resize(w);
203
- const grid: { ch: string; style: (s: string) => string }[][] = Array.from(
204
- { length: this.h },
205
- () => Array.from({ length: w }, () => ({ ch: " ", style: (s: string) => s })),
206
- );
207
- const pigment = chalk.hex(hex);
208
- for (const p of this.particles) {
209
- const visible = motion === "glint" ? Math.sin((tick + p.phase) / 3) > -0.2 : true;
210
- if (!visible) continue;
211
- const dimmed = motion === "bead" ? Math.sin((tick + p.phase) / 5) < 0 : (p.phase % 3 === 0);
212
- const x = Math.min(w - 1, Math.round(p.x));
213
- const y = Math.min(this.h - 1, Math.round(p.y));
214
- grid[y][x] = { ch: symbol, style: dimmed ? (s) => pigment.dim(s) : (s) => pigment(s) };
215
- }
216
- // Loom shuttles: a thread of ┄ in the agent's pigment, shuttle glyph at the head.
217
- for (const sh of shuttles) {
218
- const row = sh.row % this.h;
219
- const head = Math.round(sh.x) % w;
220
- const thread = chalk.hex(sh.hex).dim;
221
- for (let x = 0; x < head; x++) if (grid[row][x].ch === " ") grid[row][x] = { ch: "┄", style: thread };
222
- grid[row][head] = { ch: sh.symbol, style: chalk.hex(sh.hex).bold };
223
- }
224
- return grid.map((cells) => {
225
- let line = "";
226
- for (const c of cells) line += c.ch === " " ? " " : c.style(c.ch);
227
- return line;
228
- });
229
- }
230
- }
231
-
232
- /** Distant-mountain silhouette; grows slowly as the session lengthens. */
233
- export function mountainRow(width: number, turns: number): string {
234
- const GLYPHS = [" ", "▁", "▂", "▃", "▄", "▅"];
235
- const growth = Math.min(1, turns / 30) * 0.7 + 0.3;
236
- let out = "";
237
- for (let x = 0; x < width; x++) {
238
- // layered sines make a credible ridge; deterministic, so the diff renderer
239
- // only repaints this row when `turns` changes.
240
- const r = Math.sin(x / 6.1) * 0.5 + Math.sin(x / 13.7 + 2) * 0.35 + Math.sin(x / 3.3 + 5) * 0.15;
241
- const h = Math.max(0, Math.round((r * 0.5 + 0.5) * (GLYPHS.length - 1) * growth));
242
- out += GLYPHS[h];
243
- }
244
- return chalk.hex(PALETTE.inkFaint).dim(out);
245
- }
246
-
247
- /* ════════════════════════════════════════
248
- Viewport blocks
249
- ════════════════════════════════════════ */
250
-
251
- /**
252
- * "text" blocks hold plain text, wrapped CJK-aware at render time, styled
253
- * per line. "line" blocks are pre-styled single lines (tasks, tool events)
254
- * that are truncated rather than wrapped, and may be updated in place by id.
255
- */
256
- export interface Block {
257
- kind: "text" | "line" | "blank";
258
- text: string;
259
- /** style applied to each wrapped line of a "text" block */
260
- style?: (s: string) => string;
261
- /** left gutter prefix for "text" blocks (first line only) */
262
- head?: string;
263
- id?: string;
264
- /** the open block receives stream writes + ink-bleed cursor */
265
- open?: boolean;
266
- version: number;
267
- cache?: { width: number; version: number; lines: string[] };
268
- }
269
-
270
- const CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳";
271
- export function circled(i: number): string { return CIRCLED[i] ?? `(${i + 1})`; }
272
-
273
- /* ════════════════════════════════════════
274
- Orchestration state (multi-agent dynamics)
275
- ════════════════════════════════════════ */
276
-
277
- export interface OrchTask {
278
- id: string;
279
- index: number;
280
- agent: string;
281
- desc: string;
282
- deps: string[];
283
- state: "wait" | "run" | "ok" | "fail";
284
- startedAt?: number;
285
- ms?: number;
286
- }
287
-
288
- export class OrchState {
289
- tasks = new Map<string, OrchTask>();
290
- order: string[] = [];
291
- active = false;
292
- /** moving shuttle x-position per running agent */
293
- shuttleX = new Map<string, number>();
294
-
295
- plan(raw: any[]) {
296
- this.active = true;
297
- for (const t of raw) {
298
- if (this.tasks.has(t.id)) continue;
299
- this.tasks.set(t.id, {
300
- id: t.id,
301
- index: this.order.length,
302
- agent: t.assignedTo || "fog",
303
- desc: String(t.description || "").split("\n")[0],
304
- deps: (t.allDeps || []).slice(),
305
- state: "wait",
306
- });
307
- this.order.push(t.id);
308
- }
309
- }
310
-
311
- start(id: string) {
312
- const t = this.tasks.get(id);
313
- if (t) { t.state = "run"; t.startedAt = Date.now(); this.shuttleX.set(t.agent, 0); }
314
- }
315
-
316
- done(id: string, ok: boolean) {
317
- const t = this.tasks.get(id);
318
- if (!t) return;
319
- t.state = ok ? "ok" : "fail";
320
- t.ms = t.startedAt ? Date.now() - t.startedAt : undefined;
321
- if (![...this.tasks.values()].some((x) => x.state === "run" && x.agent === t.agent)) {
322
- this.shuttleX.delete(t.agent);
323
- }
324
- }
325
-
326
- finish() { this.active = false; this.shuttleX.clear(); }
327
-
328
- runningAgents(): string[] {
329
- return [...new Set([...this.tasks.values()].filter((t) => t.state === "run").map((t) => t.agent))];
330
- }
331
-
332
- /** Per-agent ✓/✗ tally for the rail. */
333
- tally(agent: string): { ok: number; fail: number; run: boolean } {
334
- let ok = 0, fail = 0, run = false;
335
- for (const t of this.tasks.values()) {
336
- if (t.agent !== agent) continue;
337
- if (t.state === "ok") ok++;
338
- else if (t.state === "fail") fail++;
339
- else if (t.state === "run") run = true;
340
- }
341
- return { ok, fail, run };
342
- }
343
-
344
- progress(): { done: number; total: number } {
345
- let done = 0;
346
- for (const t of this.tasks.values()) if (t.state === "ok" || t.state === "fail") done++;
347
- return { done, total: this.tasks.size };
348
- }
349
- }
350
-
351
- /* ════════════════════════════════════════
352
- LoomUI — the hanging-scroll interface
353
- ════════════════════════════════════════ */
354
-
355
- export interface LoomOpts {
356
- out?: OutLike;
357
- inp?: NodeJS.ReadStream | null;
358
- /** disable timers/raw-mode for tests */
359
- headless?: boolean;
360
- }
361
-
362
- const RAIL_W = 15; // visual columns of the left rail (inside borders)
363
- const SKY_H = 2;
364
-
365
- /* Mouse: SGR extended tracking (1006) + button tracking (1000). We only ever
366
- *act* on wheel events; clicks/drags are parsed-and-swallowed so they can
367
- never leak into the input line. Shift+drag still selects text in most
368
- terminals (they bypass app tracking), so copy/paste keeps working. */
369
- const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
370
- const MOUSE_OFF = "\x1b[?1000l\x1b[?1006l";
371
- const WHEEL_STEP = 3; // viewport lines per wheel notch
372
-
373
- export class LoomUI {
374
- private out: OutLike;
375
- private inp: NodeJS.ReadStream | null;
376
- private screen: Screen;
377
- private sky = new SkyField(SKY_H);
378
- private blocks: Block[] = [];
379
- private byId = new Map<string, Block>();
380
- private tick = 0;
381
- private timer: NodeJS.Timeout | null = null;
382
- private headless: boolean;
383
- private destroyed = false;
384
-
385
- agentName = "fog";
386
- turns = 0;
387
- busy = false;
388
- busyLabel = "";
389
- orch = new OrchState();
390
-
391
- /** status providers (wired by the chat loop) */
392
- statusRight: () => string = () => "";
393
-
394
- // input editor state
395
- private inputGlyphs: string[] = []; // glyphs
396
- private cursor = 0;
397
- private history: string[] = [];
398
- private histIdx = -1;
399
- private histStash = "";
400
- private scrollOff = 0; // 0 = follow tail
401
- private paletteIdx = 0;
402
- private pendingResolve: ((s: string) => void) | null = null;
403
- private modal: { text: string; resolve: (ok: boolean) => void } | null = null;
404
- private sigintAt = 0;
405
- onInterrupt: (() => void) | null = null;
406
- /** Shift+Tab cycles interactive modes (default/plan/auto); wired by the chat loop. */
407
- onModeCycle: (() => void) | null = null;
408
- /** Styled mode badge shown in the status divider when idle ('' = default). */
409
- modeBadge = "";
410
- /** User-defined slash commands shown in the palette ([name, description]). */
411
- extraCommands: [string, string][] = [];
412
- private keypressHandler: ((str: string, key: any) => void) | null = null;
413
- private resizeHandler: (() => void) | null = null;
414
-
415
- constructor(opts?: LoomOpts) {
416
- this.out = opts?.out ?? (process.stdout as OutLike);
417
- this.inp = opts?.inp === undefined ? process.stdin : opts.inp;
418
- this.headless = opts?.headless ?? false;
419
- this.screen = new Screen(this.out);
420
- }
421
-
422
- /* ── lifecycle ── */
423
-
424
- start() {
425
- if (!this.headless) {
426
- this.out.write("\x1b[?1049h\x1b[2J" + MOUSE_ON); // alternate screen + mouse wheel
427
- if (this.inp && this.inp.isTTY) {
428
- readline.emitKeypressEvents(this.inp);
429
- this.inp.setRawMode(true);
430
- this.inp.resume();
431
- this.keypressHandler = (str, key) => this.onKey(str, key);
432
- this.inp.on("keypress", this.keypressHandler);
433
- }
434
- this.resizeHandler = () => { this.screen.invalidate(); this.invalidateWraps(); this.paint(); };
435
- (process.stdout as any).on?.("resize", this.resizeHandler);
436
- this.timer = setInterval(() => this.frame(), 120);
437
- }
438
- this.paint();
439
- }
440
-
441
- destroy() {
442
- if (this.destroyed) return;
443
- this.destroyed = true;
444
- if (this.timer) clearInterval(this.timer);
445
- if (this.inp && this.keypressHandler) this.inp.removeListener("keypress", this.keypressHandler);
446
- if (this.resizeHandler) (process.stdout as any).removeListener?.("resize", this.resizeHandler);
447
- if (!this.headless) {
448
- if (this.inp && this.inp.isTTY) this.inp.setRawMode(false);
449
- this.out.write(MOUSE_OFF + "\x1b[?1049l\x1b[?25h");
450
- }
451
- }
452
-
453
- /** Temporarily leave the loom (setup wizard etc.), then restore. */
454
- async suspend<T>(fn: () => Promise<T>): Promise<T> {
455
- if (this.inp && this.inp.isTTY) this.inp.setRawMode(false);
456
- if (this.inp && this.keypressHandler) this.inp.removeListener("keypress", this.keypressHandler);
457
- if (this.timer) { clearInterval(this.timer); this.timer = null; }
458
- this.out.write(MOUSE_OFF + "\x1b[?1049l\x1b[?25h");
459
- try {
460
- return await fn();
461
- } finally {
462
- this.out.write("\x1b[?1049h\x1b[2J" + MOUSE_ON);
463
- if (this.inp && this.inp.isTTY) {
464
- this.inp.setRawMode(true);
465
- this.inp.resume();
466
- if (this.keypressHandler) this.inp.on("keypress", this.keypressHandler);
467
- }
468
- this.screen.invalidate();
469
- if (!this.headless) this.timer = setInterval(() => this.frame(), 120);
470
- this.paint();
471
- }
472
- }
473
-
474
- /* ── block API ── */
475
-
476
- private push(b: Omit<Block, "version">): Block {
477
- const blk: Block = { version: 0, ...b };
478
- this.blocks.push(blk);
479
- if (blk.id) this.byId.set(blk.id, blk);
480
- if (this.blocks.length > 3000) {
481
- const drop = this.blocks.splice(0, 500);
482
- for (const d of drop) if (d.id) this.byId.delete(d.id);
483
- }
484
- // Note: we intentionally do NOT reset scrollOff here. When the user is at
485
- // the tail (scrollOff === 0) the view keeps following new content; when
486
- // they have scrolled up to read, their position is preserved instead of
487
- // being yanked to the bottom on every tool event or blank line.
488
- return blk;
489
- }
490
-
491
- blank() { this.push({ kind: "blank", text: "" }); }
492
-
493
- /** Pre-styled single line (truncated, never wrapped). */
494
- line(text: string, id?: string) {
495
- if (id && this.byId.has(id)) { this.update(id, text); return; }
496
- this.push({ kind: "line", text, id });
497
- }
498
-
499
- update(id: string, text: string) {
500
- const b = this.byId.get(id);
501
- if (b && b.text !== text) { b.text = text; b.version++; }
502
- }
503
-
504
- /** Wrapped plain-text block. With an id, later calls update it in place. */
505
- text(text: string, style?: (s: string) => string, head?: string, id?: string) {
506
- if (id && this.byId.has(id)) { this.update(id, text); return; }
507
- this.push({ kind: "text", text, style, head, id });
508
- }
509
-
510
- /* ── streaming ── */
511
-
512
- private openBlock: Block | null = null;
513
- private bleedLen = 0;
514
-
515
- beginStream(agentName: string) {
516
- const t = agentTheme(agentName);
517
- this.blank();
518
- this.line(chalk.bold.hex(t.hex)(`${t.symbol} ${t.kanji} `) + chalk.hex(t.hex)(t.name));
519
- this.blank();
520
- this.openBlock = this.push({ kind: "text", text: "", open: true });
521
- this.bleedLen = 0;
522
- }
523
-
524
- /** Re-open a fresh stream block (after a tool event), without the header. */
525
- continueStream() {
526
- this.endStream();
527
- this.openBlock = this.push({ kind: "text", text: "", open: true });
528
- this.bleedLen = 0;
529
- }
530
-
531
- streamWrite(s: string) {
532
- if (!this.openBlock) this.beginStream(this.agentName);
533
- const b = this.openBlock!;
534
- b.text += s.replace(/\r/g, "");
535
- b.version++;
536
- this.bleedLen = Math.min(12, this.bleedLen + [...s].length);
537
- }
538
-
539
- endStream() {
540
- if (this.openBlock) { this.openBlock.open = false; this.openBlock.version++; this.openBlock = null; }
541
- this.bleedLen = 0;
542
- }
543
-
544
- clearViewport() { this.blocks = []; this.byId.clear(); this.scrollOff = 0; this.viewportCache = null; this.paint(); }
545
-
546
- /** Transient hint in the status divider. */
547
- flash(msg: string, ms = 1600) {
548
- this.flashHint = msg;
549
- this.paint();
550
- setTimeout(() => { if (this.flashHint === msg) { this.flashHint = ""; this.paint(); } }, ms);
551
- }
552
-
553
- /* ── input ── */
554
-
555
- /** Read one submitted line (the editor stays live during streaming). */
556
- readInput(): Promise<string> {
557
- return new Promise((resolve) => { this.pendingResolve = resolve; });
558
- }
559
-
560
- /** Modal y/N confirmation (tool approval). */
561
- confirm(text: string): Promise<boolean> {
562
- return new Promise((resolve) => { this.modal = { text, resolve }; this.paint(); });
563
- }
564
-
565
- setHistory(h: string[]) { this.history = h.slice(); }
566
-
567
- // Mouse-sequence reassembly. Node's readline keypress parser does NOT emit an
568
- // SGR mouse sequence (ESC[<btn;col;rowM) as one event — it yields ESC[< then
569
- // each remaining char separately. We accumulate from the ESC[< marker until
570
- // the terminating M/m, then act, swallowing every fragment so none of it can
571
- // ever land in the input line (the bug that sank the previous attempt).
572
- private mouseBuf: string | null = null;
573
-
574
- private handleMouse(seq: string) {
575
- const m = /^(\d+);(\d+);(\d+)[Mm]$/.exec(seq);
576
- if (!m) return;
577
- const code = parseInt(m[1], 10);
578
- if ((code & 64) === 0) return; // not a wheel event (clicks/drags ignored)
579
- const down = (code & 1) !== 0; // odd button code = wheel down
580
- // When the slash palette is open, the wheel moves the selection; otherwise
581
- // it scrolls the conversation viewport.
582
- if (this.paletteMatches().length > 0 && this.inputGlyphs[0] === "/") {
583
- const n = this.paletteMatches().length;
584
- this.paletteIdx = down
585
- ? Math.min(n - 1, this.paletteIdx + 1)
586
- : Math.max(0, this.paletteIdx - 1);
587
- this.paint();
588
- return;
589
- }
590
- if (down) this.scrollOff -= WHEEL_STEP; // wheel down → toward tail
591
- else this.scrollOff += WHEEL_STEP; // wheel up → into history
592
- this.clampScroll();
593
- this.viewportCache = null;
594
- this.paint();
595
- }
596
-
597
- private onKey(str: string, key: any) {
598
- if (this.destroyed) return;
599
-
600
- // ── mouse sequence reassembly (see mouseBuf) ──
601
- if (this.mouseBuf !== null) {
602
- this.mouseBuf += str ?? "";
603
- if (str === "M" || str === "m" || this.mouseBuf.length > 24) {
604
- const seq = this.mouseBuf;
605
- this.mouseBuf = null;
606
- this.handleMouse(seq);
607
- }
608
- return; // swallow every byte of the sequence
609
- }
610
- if (key?.sequence === "\x1b[<" || str === "\x1b[<") { this.mouseBuf = ""; return; }
611
-
612
- if (this.modal) {
613
- const k = (str || "").toLowerCase();
614
- if (k === "y") { const m = this.modal; this.modal = null; m.resolve(true); }
615
- else if (k === "n" || key?.name === "return" || key?.name === "escape") {
616
- const m = this.modal; this.modal = null; m.resolve(false);
617
- }
618
- this.paint();
619
- return;
620
- }
621
-
622
- const name = key?.name;
623
- if (key?.ctrl && name === "c") { this.handleSigint(); return; }
624
-
625
- if (name === "pageup") { this.scrollOff += Math.max(1, this.bodyH() - 2); this.clampScroll(); this.paint(); return; }
626
- if (name === "pagedown") { this.scrollOff -= Math.max(1, this.bodyH() - 2); this.clampScroll(); this.paint(); return; }
627
-
628
- if (name === "return") {
629
- if (this.busy) return; // a reply is being woven; ignore submit
630
- let text = this.inputGlyphs.join("").trim();
631
-
632
- // Palette open: Enter runs the ↑↓-highlighted command (Claude Code
633
- // style). Commands that take arguments fill the input instead so the
634
- // user can type them.
635
- const matches = this.paletteMatches();
636
- if (matches.length > 0 && text.startsWith("/")) {
637
- const [cmd] = matches[Math.max(0, Math.min(this.paletteIdx, matches.length - 1))];
638
- if (cmd.endsWith(" ")) {
639
- // argument-taking command: fill the input and wait for arguments
640
- // (the palette closes once the line contains a space; a second
641
- // Enter then submits as typed)
642
- this.inputGlyphs = [...cmd];
643
- this.cursor = this.inputGlyphs.length;
644
- this.paletteIdx = 0;
645
- this.paint();
646
- return;
647
- }
648
- text = cmd.trimEnd();
649
- }
650
-
651
- this.inputGlyphs = []; this.cursor = 0; this.histIdx = -1; this.paletteIdx = 0;
652
- this.scrollOff = 0; // submitting a turn snaps back to the tail to watch the reply
653
- if (text) { this.history.unshift(text); if (this.history.length > 200) this.history.pop(); }
654
- const r = this.pendingResolve;
655
- this.pendingResolve = null;
656
- this.paint();
657
- if (r) r(text);
658
- return;
659
- }
660
-
661
- const paletteOpen = this.paletteMatches().length > 0 && this.inputGlyphs[0] === "/";
662
-
663
- if (name === "up") {
664
- if (paletteOpen) { this.paletteIdx = Math.max(0, this.paletteIdx - 1); }
665
- else if (this.histIdx < this.history.length - 1) {
666
- if (this.histIdx === -1) this.histStash = this.inputGlyphs.join("");
667
- this.histIdx++;
668
- this.inputGlyphs = [...this.history[this.histIdx]]; this.cursor = this.inputGlyphs.length;
669
- }
670
- this.paint(); return;
671
- }
672
- if (name === "down") {
673
- if (paletteOpen) { this.paletteIdx = Math.min(this.paletteMatches().length - 1, this.paletteIdx + 1); }
674
- else if (this.histIdx >= 0) {
675
- this.histIdx--;
676
- this.inputGlyphs = [...(this.histIdx === -1 ? this.histStash : this.history[this.histIdx])];
677
- this.cursor = this.inputGlyphs.length;
678
- }
679
- this.paint(); return;
680
- }
681
- if (name === "tab" && key?.shift) { this.onModeCycle?.(); this.paint(); return; }
682
- if (name === "tab") {
683
- if (paletteOpen) {
684
- const m = this.paletteMatches();
685
- const pick = m[Math.min(this.paletteIdx, m.length - 1)];
686
- if (pick) { this.inputGlyphs = [...pick[0].trimEnd()]; this.cursor = this.inputGlyphs.length; }
687
- }
688
- this.paint(); return;
689
- }
690
- if (name === "escape") {
691
- // Esc closes the palette by clearing the slash input; otherwise it
692
- // just resets selection / jumps back to the tail.
693
- if (paletteOpen) { this.inputGlyphs = []; this.cursor = 0; }
694
- this.paletteIdx = 0; this.scrollOff = 0; this.paint(); return;
695
- }
696
- if (name === "backspace") {
697
- if (this.cursor > 0) { this.inputGlyphs.splice(this.cursor - 1, 1); this.cursor--; }
698
- this.paletteIdx = 0; // filter changed — selection restarts at the top
699
- this.paint(); return;
700
- }
701
- if (name === "delete") { if (this.cursor < this.inputGlyphs.length) this.inputGlyphs.splice(this.cursor, 1); this.paint(); return; }
702
- if (name === "left") { if (this.cursor > 0) this.cursor--; this.paint(); return; }
703
- if (name === "right") { if (this.cursor < this.inputGlyphs.length) this.cursor++; this.paint(); return; }
704
- if (name === "home") { this.cursor = 0; this.paint(); return; }
705
- if (name === "end") { this.cursor = this.inputGlyphs.length; this.paint(); return; }
706
- if (key?.ctrl && name === "a") { this.cursor = 0; this.paint(); return; }
707
- if (key?.ctrl && name === "e") { this.cursor = this.inputGlyphs.length; this.paint(); return; }
708
- if (key?.ctrl && name === "u") { this.inputGlyphs.splice(0, this.cursor); this.cursor = 0; this.paint(); return; }
709
- if (key?.ctrl && name === "w") {
710
- let i = this.cursor;
711
- while (i > 0 && this.inputGlyphs[i - 1] === " ") i--;
712
- while (i > 0 && this.inputGlyphs[i - 1] !== " ") i--;
713
- this.inputGlyphs.splice(i, this.cursor - i); this.cursor = i;
714
- this.paint(); return;
715
- }
716
- if (key?.ctrl && name === "l") { this.clearViewport(); return; }
717
-
718
- if (str && !key?.ctrl && !key?.meta) {
719
- const glyphs = [...str].filter((c) => c >= " " || charWidth(c.codePointAt(0)!) > 0);
720
- if (glyphs.length) {
721
- this.inputGlyphs.splice(this.cursor, 0, ...glyphs);
722
- this.cursor += glyphs.length;
723
- this.histIdx = -1;
724
- this.paletteIdx = 0; // filter changed — selection restarts at the top
725
- this.paint();
726
- }
727
- }
728
- }
729
-
730
- private handleSigint() {
731
- const now = Date.now();
732
- if (this.busy && this.onInterrupt) {
733
- this.onInterrupt();
734
- return;
735
- }
736
- if (now - this.sigintAt < 1500) {
737
- this.destroy();
738
- process.stdout.write(chalk.dim(" 再会。\n"));
739
- process.exit(0);
740
- }
741
- this.sigintAt = now;
742
- this.flash("再按一次 Ctrl-C 退出");
743
- }
744
-
745
- private flashHint = "";
746
-
747
- private paletteMatches(): [string, string][] {
748
- const l = this.inputGlyphs.join("");
749
- if (!l.startsWith("/") || l.includes(" ")) return [];
750
- return [...SLASH_COMMANDS, ...this.extraCommands].filter(([c]) => c.trimEnd().startsWith(l));
751
- }
752
-
753
- /* ── geometry ── */
754
-
755
- private cols(): number { return Math.max(40, this.out.columns || 80); }
756
- private rows(): number { return Math.max(12, this.out.rows || 24); }
757
- // header(1) + sky(2) + body + divider(1) + input(1) + bottom(1) = rows
758
- private bodyH(): number { return this.rows() - SKY_H - 4; }
759
-
760
- private clampScroll() {
761
- const total = this.viewportLines().length;
762
- const maxOff = Math.max(0, total - this.bodyH());
763
- this.scrollOff = Math.max(0, Math.min(this.scrollOff, maxOff));
764
- }
765
-
766
- private invalidateWraps() { for (const b of this.blocks) b.cache = undefined; }
767
-
768
- /* ── frame composition ── */
769
-
770
- private frame() {
771
- this.tick++;
772
- const animate = this.busy || this.orch.active;
773
- // advance shuttles
774
- if (this.orch.active) {
775
- for (const [a, x] of this.orch.shuttleX) this.orch.shuttleX.set(a, x + 1.3);
776
- }
777
- // ink "dries": the bleed tail shrinks even when no new tokens arrive
778
- if (this.openBlock && this.bleedLen > 0 && this.tick % 2 === 0) this.bleedLen = Math.max(0, this.bleedLen - 2);
779
- if (animate || this.tick % 5 === 0) {
780
- const t = agentTheme(this.agentName);
781
- this.sky.step(t.motion, this.tick);
782
- this.paint();
783
- }
784
- }
785
-
786
- private viewportCache: { lines: string[]; key: string } | null = null;
787
-
788
- private viewportLines(): string[] {
789
- const w = this.viewW();
790
- // the open block's cursor pulse + bleed tail animate with the clock
791
- const anim = this.openBlock ? `|b${this.bleedLen}|t${this.tick & 7}` : "";
792
- const key = this.blocks.map((b) => b.version).join(",") + `|${w}|${this.blocks.length}` + anim;
793
- if (this.viewportCache && this.viewportCache.key === key) return this.viewportCache.lines;
794
- const lines: string[] = [];
795
- for (const b of this.blocks) {
796
- if (b.kind === "blank") { lines.push(""); continue; }
797
- if (b.kind === "line") { lines.push(cutVisual(b.text, w)); continue; }
798
- if (!b.cache || b.cache.width !== w || b.cache.version !== b.version) {
799
- const wrapped = wrapPlain(b.text, b.head ? w - visualWidth(b.head) : w);
800
- b.cache = { width: w, version: b.version, lines: wrapped };
801
- }
802
- const style = b.style ?? ((s: string) => s);
803
- b.cache.lines.forEach((ln, i) => {
804
- const head = b.head ? (i === 0 ? b.head : " ".repeat(visualWidth(b.head))) : "";
805
- let body = style(ln);
806
- if (b.open && i === b.cache!.lines.length - 1) {
807
- // ink-bleed: the freshest glyphs render faint, "drying" into full ink
808
- const glyphs = [...ln];
809
- const bleed = Math.min(this.bleedLen, glyphs.length);
810
- if (bleed > 0) {
811
- const headPart = glyphs.slice(0, glyphs.length - bleed).join("");
812
- const tailPart = glyphs.slice(glyphs.length - bleed).join("");
813
- body = style(headPart) + chalk.hex(PALETTE.inkLight)(tailPart);
814
- }
815
- body += this.tick % 8 < 4 ? chalk.hex(agentTheme(this.agentName).hex)("▍") : chalk.dim("▏");
816
- }
817
- lines.push(head + body);
818
- });
819
- }
820
- this.viewportCache = { lines, key };
821
- return lines;
822
- }
823
-
824
- // borders(2) + rail + rail-border(1) + gutter(1)
825
- private viewW(): number { return this.cols() - 2 - RAIL_W - 2; }
826
-
827
- private railLines(h: number): string[] {
828
- const out: string[] = [];
829
- const W = RAIL_W;
830
- out.push("");
831
- for (const name of AGENT_ORDER) {
832
- const t = agentTheme(name);
833
- const active = name === this.agentName;
834
- const tally = this.orch.tally(name);
835
- let marker: string;
836
- if (tally.run) marker = this.tick % 2 ? chalk.hex(t.hex)(t.symbol) : chalk.hex(t.hex).dim(t.symbol);
837
- else marker = active ? chalk.hex(t.hex)("●") : chalk.hex(PALETTE.inkFaint)("·");
838
- let badge = "";
839
- if (tally.ok) badge += chalk.hex("#3a7a6e")(` ✓${tally.ok}`);
840
- if (tally.fail) badge += chalk.hex("#b3342d")(` ✗${tally.fail}`);
841
- const label = active ? chalk.bold.hex(t.hex)(`${t.kanji} ${t.name}`) : chalk.hex(t.hex).dim(`${t.kanji} ${t.name}`);
842
- out.push(padAnsi(` ${marker} ${label}${badge}`, W));
843
- }
844
- out.push(chalk.hex(PALETTE.inkFaint)(" " + "╌".repeat(W - 2)));
845
- const t = agentTheme(this.agentName);
846
- for (const ln of wrapPlain(t.poem, W - 2).slice(0, 2)) {
847
- out.push(" " + chalk.hex(PALETTE.inkLight).italic(ln));
848
- }
849
- out.push(" " + chalk.hex(PALETTE.inkLight).dim(t.pigment));
850
- if (this.orch.active) {
851
- const p = this.orch.progress();
852
- out.push("");
853
- out.push(" " + chalk.hex(t.hex)(`織 ${p.done}/${p.total}`) + chalk.dim(" 梭"));
854
- }
855
- while (out.length < h) out.push("");
856
- return out.slice(0, h);
857
- }
858
-
859
- /** Compose and flush a frame. Returns the composed rows (used by tests). */
860
- paint(): string[] {
861
- if (this.destroyed) return [];
862
- const cols = this.cols();
863
- const rows = this.rows();
864
- const innerW = cols - 2;
865
- const t = agentTheme(this.agentName);
866
- const frame: string[] = [];
867
- const faint = chalk.hex(PALETTE.inkFaint);
868
- const B = (s: string) => faint(s);
869
-
870
- if (cols < 60 || rows < 14) {
871
- const small = [chalk.yellow(" 窗口太小 · 请放大终端 (≥60×14) ")];
872
- this.screen.flush(small, null);
873
- return small;
874
- }
875
-
876
- // ── header: title + seal ──
877
- {
878
- const seal = chalk.bgHex(t.hex).hex(PALETTE.paper).bold(` ${t.kanji} `);
879
- const title = chalk.bold(" 天空织机 ") + chalk.dim("Skyloom ");
880
- // ┌─ title ───…─ seal ─┐ → 2 + w(title) + fill + 4 + 2 = cols
881
- const fill = innerW - visualWidth(title) - 6;
882
- frame.push(B("┌─") + title + B("─".repeat(Math.max(0, fill))) + seal + B("─┐"));
883
- }
884
-
885
- // ── sky band ──
886
- {
887
- const shuttles = this.orch.active
888
- ? this.orch.runningAgents().map((a, i) => {
889
- const th = agentTheme(a);
890
- return { symbol: th.symbol, hex: th.hex, x: (this.orch.shuttleX.get(a) || 0) % innerW, row: i };
891
- })
892
- : [];
893
- const skyRows = this.sky.render(innerW, t.motion, t.symbol, t.hex, this.tick, shuttles);
894
- const mountain = mountainRow(innerW, this.turns);
895
- frame.push(B("│") + padAnsi(skyRows[0], innerW) + B("│"));
896
- // mountain sits behind the lower particle row: particles overlay where present
897
- frame.push(B("│") + overlay(mountain, skyRows[1], innerW) + B("│"));
898
- }
899
-
900
- // ── body: rail │ viewport ──
901
- const bodyH = this.bodyH();
902
- const rail = this.railLines(bodyH);
903
- const view = this.viewportLines();
904
- this.clampScroll();
905
- const start = Math.max(0, view.length - bodyH - this.scrollOff);
906
- const visible = view.slice(start, start + bodyH);
907
- for (let i = 0; i < bodyH; i++) {
908
- const left = padAnsi(rail[i] ?? "", RAIL_W);
909
- const right = padAnsi(visible[i] ?? "", this.viewW());
910
- frame.push(B("│") + left + B("│") + " " + right + B("│"));
911
- }
912
-
913
- // ── status divider ──
914
- {
915
- let leftLabel = "";
916
- if (this.modal) leftLabel = "";
917
- else if (this.busy && this.busyLabel) {
918
- const dots = ["· ", "·· ", "···", " ··", " ·", " "][this.tick % 6];
919
- leftLabel = ` ${chalk.hex(t.hex)(t.symbol)} ${chalk.dim(this.busyLabel + " " + dots)} `;
920
- } else if (this.flashHint) leftLabel = " " + chalk.yellow(this.flashHint) + " ";
921
- else if (this.scrollOff > 0) leftLabel = " " + chalk.dim(`↑ 回看中 · Esc 回到末尾`) + " ";
922
- else if (this.modeBadge) leftLabel = " " + this.modeBadge + " ";
923
- const right = this.statusRight();
924
- const rightLabel = right ? ` ${right} ` : "";
925
- const fill = innerW - visualWidth(leftLabel) - visualWidth(rightLabel);
926
- frame.push(B("├") + leftLabel + B("─".repeat(Math.max(0, fill))) + rightLabel + B("┤"));
927
- }
928
-
929
- // ── palette overlay (replaces tail viewport rows visually — drawn over input-adjacent rows) ──
930
- // (kept simple: palette renders inside the viewport's final rows via paint order below)
931
-
932
- // ── input row ──
933
- let cursorPos: { row: number; col: number } | null = null;
934
- {
935
- let content: string;
936
- if (this.modal) {
937
- content = " " + chalk.yellow("⚠ ") + cutVisual(this.modal.text, innerW - 14) + chalk.bold(" 允许? ") + chalk.dim("[y/N]");
938
- cursorPos = { row: rows - 2, col: Math.min(innerW, visualWidth(content) + 1) };
939
- } else {
940
- const promptStr = chalk.hex(t.hex)(` ${t.symbol} `) + chalk.hex(PALETTE.inkLight)("❯ ");
941
- const promptW = visualWidth(promptStr);
942
- const avail = innerW - promptW - 1;
943
- // horizontal scroll window around the cursor
944
- const glyphs = this.inputGlyphs;
945
- let beforeW = 0;
946
- for (let i = 0; i < this.cursor; i++) beforeW += charWidth(glyphs[i].codePointAt(0)!);
947
- let startIdx = 0, skipW = 0;
948
- while (beforeW - skipW > avail - 2 && startIdx < glyphs.length) {
949
- skipW += charWidth(glyphs[startIdx].codePointAt(0)!);
950
- startIdx++;
951
- }
952
- let shown = "", shownW = 0, cursorCol = promptW + (beforeW - skipW);
953
- for (let i = startIdx; i < glyphs.length; i++) {
954
- const cw = charWidth(glyphs[i].codePointAt(0)!);
955
- if (shownW + cw > avail) break;
956
- shown += glyphs[i]; shownW += cw;
957
- }
958
- content = promptStr + shown;
959
- cursorPos = { row: rows - 2, col: 1 + cursorCol };
960
- }
961
- frame.push(B("│") + padAnsi(content, innerW) + B("│"));
962
- }
963
-
964
- // ── bottom border with hints ──
965
- {
966
- const paletteUp = this.paletteMatches().length > 0 && this.inputGlyphs[0] === "/";
967
- const hint = this.busy
968
- ? " Ctrl-C 中断本轮 "
969
- : paletteUp
970
- ? " ↑↓ 选命令 · Enter 执行 · Tab 补全 · Esc 收起 "
971
- : " / 命令 · 滚轮/PgUp 回看 · Shift+Tab 切模式 · Ctrl-C 退出 ";
972
- // └─ hint ───…┘ → 2 + w(hint) + fill + 1 = cols
973
- const fill = innerW - visualWidth(hint) - 1;
974
- frame.push(B("└─") + chalk.dim(hint) + B("─".repeat(Math.max(0, fill)) + "┘"));
975
- }
976
-
977
- // ── slash palette: overlay onto the rows just above the divider ──
978
- const matches = this.paletteMatches();
979
- if (matches.length > 0 && this.inputGlyphs[0] === "/" && !this.modal) {
980
- const maxShow = Math.min(8, bodyH - 1);
981
- this.paletteIdx = Math.max(0, Math.min(this.paletteIdx, matches.length - 1));
982
- // scroll window that keeps the ↑↓ selection visible
983
- const start = Math.max(0, Math.min(this.paletteIdx - maxShow + 1, matches.length - maxShow));
984
- const show = matches.slice(start, start + maxShow);
985
- const baseRow = 1 + SKY_H + bodyH - show.length; // first overlay row index in frame
986
- show.forEach(([cmd, desc], i) => {
987
- const sel = start + i === this.paletteIdx;
988
- const agentCmd = ["/fog", "/rain", "/frost", "/snow", "/dew", "/fair"].includes(cmd.trim());
989
- const color = agentCmd ? chalk.hex(agentTheme(cmd.trim().slice(1)).hex) : chalk.hex(PALETTE.inkLight);
990
- const mark = sel ? chalk.hex(t.hex)(" ▸ ") : " ";
991
- const counter = sel && matches.length > maxShow ? chalk.dim(` ${this.paletteIdx + 1}/${matches.length}`) : "";
992
- const lineStr = mark + (sel ? chalk.bold(color(cmd.padEnd(11))) : color(cmd.padEnd(11))) + chalk.dim(cutVisual(desc, this.viewW() - 22)) + counter;
993
- const row = baseRow + i;
994
- // Reuse the rail already composed for the body rather than rebuilding it
995
- // per overlay row (railLines runs wrapPlain etc. — wasteful at frame rate).
996
- frame[row] = B("│") + padAnsi(rail[row - 1 - SKY_H] ?? "", RAIL_W) + B("│") + " " + padAnsi(lineStr, this.viewW()) + B("│");
997
- });
998
- }
999
-
1000
- this.screen.flush(frame, cursorPos);
1001
- return frame;
1002
- }
1003
- }
1004
-
1005
- /** Overlay `top` onto `base` (top's non-space glyphs win), to a fixed width. */
1006
- export function overlay(base: string, top: string, width: number): string {
1007
- // Both strings are styled; walk them in parallel by visual column.
1008
- const cells = (s: string): string[] => {
1009
- const out: string[] = [];
1010
- let i = 0;
1011
- let pending = "";
1012
- while (i < s.length && out.length < width * 2) {
1013
- if (s[i] === ESC) {
1014
- const m = ANSI_RE.exec(s.slice(i));
1015
- if (m && m.index === 0) { pending += m[0]; i += m[0].length; continue; }
1016
- }
1017
- const cp = s.codePointAt(i)!;
1018
- const ch = String.fromCodePoint(cp);
1019
- const cw = charWidth(cp);
1020
- out.push(pending + ch);
1021
- pending = "";
1022
- if (cw === 2) out.push(""); // wide glyph occupies two cells
1023
- i += ch.length;
1024
- }
1025
- return out;
1026
- };
1027
- const b = cells(base);
1028
- const t = cells(top);
1029
- let res = "";
1030
- for (let x = 0; x < width; x++) {
1031
- const tc = t[x];
1032
- const bc = b[x];
1033
- const topVisible = tc !== undefined && tc.replace(/\x1b\[[0-9;]*m/g, "") !== " " && tc !== "";
1034
- if (topVisible) res += tc + "\x1b[0m";
1035
- else if (tc === "") continue; // second cell of a wide top glyph
1036
- else if (bc !== undefined && bc !== "") res += bc + "\x1b[0m";
1037
- else if (bc === "") continue;
1038
- else res += " ";
1039
- }
1040
- return padAnsi(res, width);
1041
- }
1
+ /**
2
+ * 天空织机 · 立轴 — the full-screen ink-wash weather-station TUI.
3
+ *
4
+ * Architecture notes (why this one works where the old full-screen attempt
5
+ * failed):
6
+ * 1. Streamed text never touches the terminal directly. It lands in a
7
+ * virtual block buffer; every frame is composed in memory and a diff
8
+ * renderer repaints only the rows that changed. Streaming and animation
9
+ * therefore cannot fight over the cursor.
10
+ * 2. All width math goes through the CJK-aware helpers in tui.ts, so the
11
+ * hand-rolled input editor cannot mangle fullwidth glyphs.
12
+ * 3. The animation clock is the single writer: key events and stream events
13
+ * only mutate state; the frame timer (and explicit repaint requests)
14
+ * flush it.
15
+ *
16
+ * Layout (画轴 / hanging scroll):
17
+ * ┌─ 天空织机 ───────────────────────────── ▣ 霧 ─┐ header + seal
18
+ * │ ≋ ❉ ⸽ particles / shuttles │ sky band (2 rows)
19
+ * │ ▁▂▃▅▃▂▁▁▂▄▂▁ mountain grows with the session │
20
+ * │ ● 霧 fog │ conversation viewport │ rail │ viewport
21
+ * │ · 雨 rain │ … │
22
+ * ├─ 思忖 ··· ──────────────── model · cost · ctx ─┤ status divider
23
+ * │ ≋ ❯ input │ input line
24
+ * └─ /help · Tab 补全 · PgUp 翻页 ─────────────────┘
25
+ *
26
+ * Design rationale: docs/AESTHETIC_DESIGN.md §2.2 (方案三 · 立轴).
27
+ */
28
+
29
+ import * as readline from "readline";
30
+ import chalk from "chalk";
31
+ import { agentTheme, AGENT_ORDER, PALETTE } from "../core/theme";
32
+ import { charWidth, visualWidth, SLASH_COMMANDS } from "./tui";
33
+
34
+ /* ════════════════════════════════════════
35
+ ANSI-aware string helpers (pure, tested)
36
+ ════════════════════════════════════════ */
37
+
38
+ const ESC = "\x1b";
39
+ const ANSI_RE = /\x1b\[[0-9;]*m/;
40
+
41
+ /** Truncate a styled string to a visual width, keeping ANSI sequences intact. */
42
+ export function cutVisual(s: string, maxW: number): string {
43
+ let out = "";
44
+ let w = 0;
45
+ let i = 0;
46
+ let cut = false;
47
+ while (i < s.length) {
48
+ if (s[i] === ESC) {
49
+ const m = ANSI_RE.exec(s.slice(i));
50
+ if (m && m.index === 0) { out += m[0]; i += m[0].length; continue; }
51
+ }
52
+ const cp = s.codePointAt(i)!;
53
+ const ch = String.fromCodePoint(cp);
54
+ const cw = charWidth(cp);
55
+ if (w + cw > maxW) { cut = true; break; }
56
+ out += ch;
57
+ w += cw;
58
+ i += ch.length;
59
+ }
60
+ return cut ? out + "\x1b[0m" : out;
61
+ }
62
+
63
+ /** Pad a styled string with spaces to an exact visual width (truncates if over). */
64
+ export function padAnsi(s: string, w: number): string {
65
+ const cutS = visualWidth(s) > w ? cutVisual(s, w) : s;
66
+ const diff = w - visualWidth(cutS);
67
+ return diff > 0 ? cutS + " ".repeat(diff) : cutS;
68
+ }
69
+
70
+ /** CJK-aware plain-text word wrap (latin wraps on spaces, CJK per glyph). */
71
+ export function wrapPlain(text: string, width: number): string[] {
72
+ const lines: string[] = [];
73
+ if (width < 4) width = 4;
74
+ for (const raw of text.split("\n")) {
75
+ let line = "";
76
+ let col = 0;
77
+ let word = "";
78
+ const flushWord = () => {
79
+ if (!word) return;
80
+ const w = visualWidth(word);
81
+ if (col > 0 && col + w > width) { lines.push(line.trimEnd()); line = ""; col = 0; }
82
+ // hard-break monster tokens (plain glyph slicing — no ANSI involved)
83
+ while (visualWidth(word) > width) {
84
+ let head = "", hw = 0, i = 0;
85
+ for (const ch of word) {
86
+ const cw = charWidth(ch.codePointAt(0)!);
87
+ if (hw + cw > width - col) break;
88
+ head += ch; hw += cw; i += ch.length;
89
+ }
90
+ lines.push(line + head);
91
+ word = word.slice(i);
92
+ line = ""; col = 0;
93
+ }
94
+ line += word; col += visualWidth(word); word = "";
95
+ };
96
+ for (const ch of raw) {
97
+ const cp = ch.codePointAt(0)!;
98
+ if (ch === " " || ch === "\t") {
99
+ flushWord();
100
+ if (col > 0 && col < width) { line += " "; col += 1; }
101
+ continue;
102
+ }
103
+ if (charWidth(cp) === 2) {
104
+ flushWord();
105
+ if (col + 2 > width) { lines.push(line.trimEnd()); line = ""; col = 0; }
106
+ line += ch; col += 2;
107
+ continue;
108
+ }
109
+ word += ch;
110
+ }
111
+ flushWord();
112
+ lines.push(line);
113
+ }
114
+ // trim trailing blank produced by terminal newline at very end
115
+ while (lines.length > 1 && lines[lines.length - 1] === "" && text.endsWith("\n")) lines.pop();
116
+ return lines;
117
+ }
118
+
119
+ /* ════════════════════════════════════════
120
+ Screen — double-buffered diff renderer
121
+ ════════════════════════════════════════ */
122
+
123
+ export interface OutLike {
124
+ columns?: number;
125
+ rows?: number;
126
+ isTTY?: boolean;
127
+ write(s: string): boolean;
128
+ }
129
+
130
+ /** Repaints only rows whose content changed since the previous frame. */
131
+ export class Screen {
132
+ private prev: string[] = [];
133
+ constructor(private out: OutLike) {}
134
+
135
+ /** Force the next flush to repaint everything (resize / resume). */
136
+ invalidate() { this.prev = []; }
137
+
138
+ flush(rows: string[], cursor: { row: number; col: number } | null) {
139
+ let seq = "\x1b[?25l"; // hide cursor while painting
140
+ for (let i = 0; i < rows.length; i++) {
141
+ if (this.prev[i] !== rows[i]) {
142
+ seq += `\x1b[${i + 1};1H\x1b[2K` + rows[i];
143
+ }
144
+ }
145
+ if (this.prev.length > rows.length) {
146
+ for (let i = rows.length; i < this.prev.length; i++) seq += `\x1b[${i + 1};1H\x1b[2K`;
147
+ }
148
+ if (cursor) seq += `\x1b[${cursor.row + 1};${cursor.col + 1}H\x1b[?25h`;
149
+ this.out.write(seq);
150
+ this.prev = rows.slice();
151
+ }
152
+ }
153
+
154
+ /* ════════════════════════════════════════
155
+ Sky band — weather particles & loom shuttles
156
+ ════════════════════════════════════════ */
157
+
158
+ interface Particle { x: number; y: number; phase: number }
159
+
160
+ /** Per-agent weather motion over a w×2 field. drift/fall/glint/float/bead/rise. */
161
+ export class SkyField {
162
+ particles: Particle[] = [];
163
+ private w = 0;
164
+ constructor(private readonly h: number = 2) {}
165
+
166
+ resize(w: number) {
167
+ this.w = Math.max(8, w);
168
+ const n = Math.max(3, Math.floor(this.w / 7));
169
+ this.particles = Array.from({ length: n }, (_, i) => ({
170
+ x: (i * 7.3 + (i * i % 5)) % this.w,
171
+ y: (i * 13) % this.h,
172
+ phase: (i * 37) % 17,
173
+ }));
174
+ }
175
+
176
+ step(motion: string, tick: number) {
177
+ for (const p of this.particles) {
178
+ switch (motion) {
179
+ case "drift": p.x += 0.45; p.y = (Math.sin((tick + p.phase) / 6) > 0 ? 0 : 1); break;
180
+ case "fall": p.y += 0.55; p.x += 0.12; break;
181
+ case "glint": /* static, blink via phase at render */ break;
182
+ case "float": p.y += 0.28; p.x += Math.sin((tick + p.phase) / 4) * 0.5; break;
183
+ case "bead": /* static, brightness breathes */ break;
184
+ case "rise": p.y -= 0.3; break;
185
+ }
186
+ if (p.x >= this.w) p.x -= this.w;
187
+ if (p.x < 0) p.x += this.w;
188
+ if (p.y >= this.h) p.y -= this.h;
189
+ if (p.y < 0) p.y += this.h;
190
+ }
191
+ }
192
+
193
+ /** Render the two sky rows. Shuttles (orchestration) overlay the weather. */
194
+ render(
195
+ w: number,
196
+ motion: string,
197
+ symbol: string,
198
+ hex: string,
199
+ tick: number,
200
+ shuttles: { symbol: string; hex: string; x: number; row: number }[],
201
+ ): string[] {
202
+ if (w !== this.w) this.resize(w);
203
+ const grid: { ch: string; style: (s: string) => string }[][] = Array.from(
204
+ { length: this.h },
205
+ () => Array.from({ length: w }, () => ({ ch: " ", style: (s: string) => s })),
206
+ );
207
+ const pigment = chalk.hex(hex);
208
+ for (const p of this.particles) {
209
+ const visible = motion === "glint" ? Math.sin((tick + p.phase) / 3) > -0.2 : true;
210
+ if (!visible) continue;
211
+ const dimmed = motion === "bead" ? Math.sin((tick + p.phase) / 5) < 0 : (p.phase % 3 === 0);
212
+ const x = Math.min(w - 1, Math.round(p.x));
213
+ const y = Math.min(this.h - 1, Math.round(p.y));
214
+ grid[y][x] = { ch: symbol, style: dimmed ? (s) => pigment.dim(s) : (s) => pigment(s) };
215
+ }
216
+ // Loom shuttles: a thread of ┄ in the agent's pigment, shuttle glyph at the head.
217
+ for (const sh of shuttles) {
218
+ const row = sh.row % this.h;
219
+ const head = Math.round(sh.x) % w;
220
+ const thread = chalk.hex(sh.hex).dim;
221
+ for (let x = 0; x < head; x++) if (grid[row][x].ch === " ") grid[row][x] = { ch: "┄", style: thread };
222
+ grid[row][head] = { ch: sh.symbol, style: chalk.hex(sh.hex).bold };
223
+ }
224
+ return grid.map((cells) => {
225
+ let line = "";
226
+ for (const c of cells) line += c.ch === " " ? " " : c.style(c.ch);
227
+ return line;
228
+ });
229
+ }
230
+ }
231
+
232
+ /** Distant-mountain silhouette; grows slowly as the session lengthens. */
233
+ export function mountainRow(width: number, turns: number): string {
234
+ const GLYPHS = [" ", "▁", "▂", "▃", "▄", "▅"];
235
+ const growth = Math.min(1, turns / 30) * 0.7 + 0.3;
236
+ let out = "";
237
+ for (let x = 0; x < width; x++) {
238
+ // layered sines make a credible ridge; deterministic, so the diff renderer
239
+ // only repaints this row when `turns` changes.
240
+ const r = Math.sin(x / 6.1) * 0.5 + Math.sin(x / 13.7 + 2) * 0.35 + Math.sin(x / 3.3 + 5) * 0.15;
241
+ const h = Math.max(0, Math.round((r * 0.5 + 0.5) * (GLYPHS.length - 1) * growth));
242
+ out += GLYPHS[h];
243
+ }
244
+ return chalk.hex(PALETTE.inkFaint).dim(out);
245
+ }
246
+
247
+ /* ════════════════════════════════════════
248
+ Viewport blocks
249
+ ════════════════════════════════════════ */
250
+
251
+ /**
252
+ * "text" blocks hold plain text, wrapped CJK-aware at render time, styled
253
+ * per line. "line" blocks are pre-styled single lines (tasks, tool events)
254
+ * that are truncated rather than wrapped, and may be updated in place by id.
255
+ */
256
+ export interface Block {
257
+ kind: "text" | "line" | "blank";
258
+ text: string;
259
+ /** style applied to each wrapped line of a "text" block */
260
+ style?: (s: string) => string;
261
+ /** left gutter prefix for "text" blocks (first line only) */
262
+ head?: string;
263
+ id?: string;
264
+ /** the open block receives stream writes + ink-bleed cursor */
265
+ open?: boolean;
266
+ version: number;
267
+ cache?: { width: number; version: number; lines: string[] };
268
+ }
269
+
270
+ const CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳";
271
+ export function circled(i: number): string { return CIRCLED[i] ?? `(${i + 1})`; }
272
+
273
+ /* ════════════════════════════════════════
274
+ Orchestration state (multi-agent dynamics)
275
+ ════════════════════════════════════════ */
276
+
277
+ export interface OrchTask {
278
+ id: string;
279
+ index: number;
280
+ agent: string;
281
+ desc: string;
282
+ deps: string[];
283
+ state: "wait" | "run" | "ok" | "fail";
284
+ startedAt?: number;
285
+ ms?: number;
286
+ }
287
+
288
+ export class OrchState {
289
+ tasks = new Map<string, OrchTask>();
290
+ order: string[] = [];
291
+ active = false;
292
+ /** moving shuttle x-position per running agent */
293
+ shuttleX = new Map<string, number>();
294
+
295
+ plan(raw: any[]) {
296
+ this.active = true;
297
+ for (const t of raw) {
298
+ if (this.tasks.has(t.id)) continue;
299
+ this.tasks.set(t.id, {
300
+ id: t.id,
301
+ index: this.order.length,
302
+ agent: t.assignedTo || "fog",
303
+ desc: String(t.description || "").split("\n")[0],
304
+ deps: (t.allDeps || []).slice(),
305
+ state: "wait",
306
+ });
307
+ this.order.push(t.id);
308
+ }
309
+ }
310
+
311
+ start(id: string) {
312
+ const t = this.tasks.get(id);
313
+ if (t) { t.state = "run"; t.startedAt = Date.now(); this.shuttleX.set(t.agent, 0); }
314
+ }
315
+
316
+ done(id: string, ok: boolean) {
317
+ const t = this.tasks.get(id);
318
+ if (!t) return;
319
+ t.state = ok ? "ok" : "fail";
320
+ t.ms = t.startedAt ? Date.now() - t.startedAt : undefined;
321
+ if (![...this.tasks.values()].some((x) => x.state === "run" && x.agent === t.agent)) {
322
+ this.shuttleX.delete(t.agent);
323
+ }
324
+ }
325
+
326
+ finish() { this.active = false; this.shuttleX.clear(); }
327
+
328
+ runningAgents(): string[] {
329
+ return [...new Set([...this.tasks.values()].filter((t) => t.state === "run").map((t) => t.agent))];
330
+ }
331
+
332
+ /** Per-agent ✓/✗ tally for the rail. */
333
+ tally(agent: string): { ok: number; fail: number; run: boolean } {
334
+ let ok = 0, fail = 0, run = false;
335
+ for (const t of this.tasks.values()) {
336
+ if (t.agent !== agent) continue;
337
+ if (t.state === "ok") ok++;
338
+ else if (t.state === "fail") fail++;
339
+ else if (t.state === "run") run = true;
340
+ }
341
+ return { ok, fail, run };
342
+ }
343
+
344
+ progress(): { done: number; total: number } {
345
+ let done = 0;
346
+ for (const t of this.tasks.values()) if (t.state === "ok" || t.state === "fail") done++;
347
+ return { done, total: this.tasks.size };
348
+ }
349
+ }
350
+
351
+ /* ════════════════════════════════════════
352
+ LoomUI — the hanging-scroll interface
353
+ ════════════════════════════════════════ */
354
+
355
+ export interface LoomOpts {
356
+ out?: OutLike;
357
+ inp?: NodeJS.ReadStream | null;
358
+ /** disable timers/raw-mode for tests */
359
+ headless?: boolean;
360
+ }
361
+
362
+ const RAIL_W = 15; // visual columns of the left rail (inside borders)
363
+ const SKY_H = 2;
364
+
365
+ /* Mouse: SGR extended tracking (1006) + button tracking (1000). We only ever
366
+ *act* on wheel events; clicks/drags are parsed-and-swallowed so they can
367
+ never leak into the input line. Shift+drag still selects text in most
368
+ terminals (they bypass app tracking), so copy/paste keeps working. */
369
+ const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
370
+ const MOUSE_OFF = "\x1b[?1000l\x1b[?1006l";
371
+ const WHEEL_STEP = 3; // viewport lines per wheel notch
372
+
373
+ export class LoomUI {
374
+ private out: OutLike;
375
+ private inp: NodeJS.ReadStream | null;
376
+ private screen: Screen;
377
+ private sky = new SkyField(SKY_H);
378
+ private blocks: Block[] = [];
379
+ private byId = new Map<string, Block>();
380
+ private tick = 0;
381
+ private timer: NodeJS.Timeout | null = null;
382
+ private headless: boolean;
383
+ private destroyed = false;
384
+
385
+ agentName = "fog";
386
+ turns = 0;
387
+ busy = false;
388
+ busyLabel = "";
389
+ orch = new OrchState();
390
+
391
+ /** status providers (wired by the chat loop) */
392
+ statusRight: () => string = () => "";
393
+
394
+ // input editor state
395
+ private inputGlyphs: string[] = []; // glyphs
396
+ private cursor = 0;
397
+ private history: string[] = [];
398
+ private histIdx = -1;
399
+ private histStash = "";
400
+ private scrollOff = 0; // 0 = follow tail
401
+ private paletteIdx = 0;
402
+ private pendingResolve: ((s: string) => void) | null = null;
403
+ private modal: { text: string; resolve: (ok: boolean) => void } | null = null;
404
+ private sigintAt = 0;
405
+ onInterrupt: (() => void) | null = null;
406
+ /** Shift+Tab cycles interactive modes (default/plan/auto); wired by the chat loop. */
407
+ onModeCycle: (() => void) | null = null;
408
+ /** Styled mode badge shown in the status divider when idle ('' = default). */
409
+ modeBadge = "";
410
+ /** User-defined slash commands shown in the palette ([name, description]). */
411
+ extraCommands: [string, string][] = [];
412
+ private keypressHandler: ((str: string, key: any) => void) | null = null;
413
+ private resizeHandler: (() => void) | null = null;
414
+
415
+ constructor(opts?: LoomOpts) {
416
+ this.out = opts?.out ?? (process.stdout as OutLike);
417
+ this.inp = opts?.inp === undefined ? process.stdin : opts.inp;
418
+ this.headless = opts?.headless ?? false;
419
+ this.screen = new Screen(this.out);
420
+ }
421
+
422
+ /* ── lifecycle ── */
423
+
424
+ start() {
425
+ if (!this.headless) {
426
+ this.out.write("\x1b[?1049h\x1b[2J" + MOUSE_ON); // alternate screen + mouse wheel
427
+ if (this.inp && this.inp.isTTY) {
428
+ readline.emitKeypressEvents(this.inp);
429
+ this.inp.setRawMode(true);
430
+ this.inp.resume();
431
+ this.keypressHandler = (str, key) => this.onKey(str, key);
432
+ this.inp.on("keypress", this.keypressHandler);
433
+ }
434
+ this.resizeHandler = () => { this.screen.invalidate(); this.invalidateWraps(); this.paint(); };
435
+ (process.stdout as any).on?.("resize", this.resizeHandler);
436
+ this.timer = setInterval(() => this.frame(), 120);
437
+ }
438
+ this.paint();
439
+ }
440
+
441
+ destroy() {
442
+ if (this.destroyed) return;
443
+ this.destroyed = true;
444
+ if (this.timer) clearInterval(this.timer);
445
+ if (this.inp && this.keypressHandler) this.inp.removeListener("keypress", this.keypressHandler);
446
+ if (this.resizeHandler) (process.stdout as any).removeListener?.("resize", this.resizeHandler);
447
+ if (!this.headless) {
448
+ if (this.inp && this.inp.isTTY) this.inp.setRawMode(false);
449
+ this.out.write(MOUSE_OFF + "\x1b[?1049l\x1b[?25h");
450
+ }
451
+ }
452
+
453
+ /** Temporarily leave the loom (setup wizard etc.), then restore. */
454
+ async suspend<T>(fn: () => Promise<T>): Promise<T> {
455
+ if (this.inp && this.inp.isTTY) this.inp.setRawMode(false);
456
+ if (this.inp && this.keypressHandler) this.inp.removeListener("keypress", this.keypressHandler);
457
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
458
+ this.out.write(MOUSE_OFF + "\x1b[?1049l\x1b[?25h");
459
+ try {
460
+ return await fn();
461
+ } finally {
462
+ this.out.write("\x1b[?1049h\x1b[2J" + MOUSE_ON);
463
+ if (this.inp && this.inp.isTTY) {
464
+ this.inp.setRawMode(true);
465
+ this.inp.resume();
466
+ if (this.keypressHandler) this.inp.on("keypress", this.keypressHandler);
467
+ }
468
+ this.screen.invalidate();
469
+ if (!this.headless) this.timer = setInterval(() => this.frame(), 120);
470
+ this.paint();
471
+ }
472
+ }
473
+
474
+ /* ── block API ── */
475
+
476
+ private push(b: Omit<Block, "version">): Block {
477
+ const blk: Block = { version: 0, ...b };
478
+ this.blocks.push(blk);
479
+ if (blk.id) this.byId.set(blk.id, blk);
480
+ if (this.blocks.length > 3000) {
481
+ const drop = this.blocks.splice(0, 500);
482
+ for (const d of drop) if (d.id) this.byId.delete(d.id);
483
+ }
484
+ // Note: we intentionally do NOT reset scrollOff here. When the user is at
485
+ // the tail (scrollOff === 0) the view keeps following new content; when
486
+ // they have scrolled up to read, their position is preserved instead of
487
+ // being yanked to the bottom on every tool event or blank line.
488
+ return blk;
489
+ }
490
+
491
+ blank() { this.push({ kind: "blank", text: "" }); }
492
+
493
+ /** Pre-styled single line (truncated, never wrapped). */
494
+ line(text: string, id?: string) {
495
+ if (id && this.byId.has(id)) { this.update(id, text); return; }
496
+ this.push({ kind: "line", text, id });
497
+ }
498
+
499
+ update(id: string, text: string) {
500
+ const b = this.byId.get(id);
501
+ if (b && b.text !== text) { b.text = text; b.version++; }
502
+ }
503
+
504
+ /** Wrapped plain-text block. With an id, later calls update it in place. */
505
+ text(text: string, style?: (s: string) => string, head?: string, id?: string) {
506
+ if (id && this.byId.has(id)) { this.update(id, text); return; }
507
+ this.push({ kind: "text", text, style, head, id });
508
+ }
509
+
510
+ /* ── streaming ── */
511
+
512
+ private openBlock: Block | null = null;
513
+ private bleedLen = 0;
514
+
515
+ beginStream(agentName: string) {
516
+ const t = agentTheme(agentName);
517
+ this.blank();
518
+ this.line(chalk.bold.hex(t.hex)(`${t.symbol} ${t.kanji} `) + chalk.hex(t.hex)(t.name));
519
+ this.blank();
520
+ this.openBlock = this.push({ kind: "text", text: "", open: true });
521
+ this.bleedLen = 0;
522
+ }
523
+
524
+ /** Re-open a fresh stream block (after a tool event), without the header. */
525
+ continueStream() {
526
+ this.endStream();
527
+ this.openBlock = this.push({ kind: "text", text: "", open: true });
528
+ this.bleedLen = 0;
529
+ }
530
+
531
+ streamWrite(s: string) {
532
+ if (!this.openBlock) this.beginStream(this.agentName);
533
+ const b = this.openBlock!;
534
+ b.text += s.replace(/\r/g, "");
535
+ b.version++;
536
+ this.bleedLen = Math.min(12, this.bleedLen + [...s].length);
537
+ }
538
+
539
+ endStream() {
540
+ if (this.openBlock) { this.openBlock.open = false; this.openBlock.version++; this.openBlock = null; }
541
+ this.bleedLen = 0;
542
+ }
543
+
544
+ clearViewport() { this.blocks = []; this.byId.clear(); this.scrollOff = 0; this.viewportCache = null; this.paint(); }
545
+
546
+ /** Transient hint in the status divider. */
547
+ flash(msg: string, ms = 1600) {
548
+ this.flashHint = msg;
549
+ this.paint();
550
+ setTimeout(() => { if (this.flashHint === msg) { this.flashHint = ""; this.paint(); } }, ms);
551
+ }
552
+
553
+ /* ── input ── */
554
+
555
+ /** Read one submitted line (the editor stays live during streaming). */
556
+ readInput(): Promise<string> {
557
+ return new Promise((resolve) => { this.pendingResolve = resolve; });
558
+ }
559
+
560
+ /** Modal y/N confirmation (tool approval). */
561
+ confirm(text: string): Promise<boolean> {
562
+ return new Promise((resolve) => { this.modal = { text, resolve }; this.paint(); });
563
+ }
564
+
565
+ setHistory(h: string[]) { this.history = h.slice(); }
566
+
567
+ // Mouse-sequence reassembly. Node's readline keypress parser does NOT emit an
568
+ // SGR mouse sequence (ESC[<btn;col;rowM) as one event — it yields ESC[< then
569
+ // each remaining char separately. We accumulate from the ESC[< marker until
570
+ // the terminating M/m, then act, swallowing every fragment so none of it can
571
+ // ever land in the input line (the bug that sank the previous attempt).
572
+ private mouseBuf: string | null = null;
573
+
574
+ private handleMouse(seq: string) {
575
+ const m = /^(\d+);(\d+);(\d+)[Mm]$/.exec(seq);
576
+ if (!m) return;
577
+ const code = parseInt(m[1], 10);
578
+ if ((code & 64) === 0) return; // not a wheel event (clicks/drags ignored)
579
+ const down = (code & 1) !== 0; // odd button code = wheel down
580
+ // When the slash palette is open, the wheel moves the selection; otherwise
581
+ // it scrolls the conversation viewport.
582
+ if (this.paletteMatches().length > 0 && this.inputGlyphs[0] === "/") {
583
+ const n = this.paletteMatches().length;
584
+ this.paletteIdx = down
585
+ ? Math.min(n - 1, this.paletteIdx + 1)
586
+ : Math.max(0, this.paletteIdx - 1);
587
+ this.paint();
588
+ return;
589
+ }
590
+ if (down) this.scrollOff -= WHEEL_STEP; // wheel down → toward tail
591
+ else this.scrollOff += WHEEL_STEP; // wheel up → into history
592
+ this.clampScroll();
593
+ this.viewportCache = null;
594
+ this.paint();
595
+ }
596
+
597
+ private onKey(str: string, key: any) {
598
+ if (this.destroyed) return;
599
+
600
+ // ── mouse sequence reassembly (see mouseBuf) ──
601
+ if (this.mouseBuf !== null) {
602
+ this.mouseBuf += str ?? "";
603
+ if (str === "M" || str === "m" || this.mouseBuf.length > 24) {
604
+ const seq = this.mouseBuf;
605
+ this.mouseBuf = null;
606
+ this.handleMouse(seq);
607
+ }
608
+ return; // swallow every byte of the sequence
609
+ }
610
+ if (key?.sequence === "\x1b[<" || str === "\x1b[<") { this.mouseBuf = ""; return; }
611
+
612
+ if (this.modal) {
613
+ const k = (str || "").toLowerCase();
614
+ if (k === "y") { const m = this.modal; this.modal = null; m.resolve(true); }
615
+ else if (k === "n" || key?.name === "return" || key?.name === "escape") {
616
+ const m = this.modal; this.modal = null; m.resolve(false);
617
+ }
618
+ this.paint();
619
+ return;
620
+ }
621
+
622
+ const name = key?.name;
623
+ if (key?.ctrl && name === "c") { this.handleSigint(); return; }
624
+
625
+ if (name === "pageup") { this.scrollOff += Math.max(1, this.bodyH() - 2); this.clampScroll(); this.paint(); return; }
626
+ if (name === "pagedown") { this.scrollOff -= Math.max(1, this.bodyH() - 2); this.clampScroll(); this.paint(); return; }
627
+
628
+ if (name === "return") {
629
+ if (this.busy) return; // a reply is being woven; ignore submit
630
+ let text = this.inputGlyphs.join("").trim();
631
+
632
+ // Palette open: Enter runs the ↑↓-highlighted command (Claude Code
633
+ // style). Commands that take arguments fill the input instead so the
634
+ // user can type them.
635
+ const matches = this.paletteMatches();
636
+ if (matches.length > 0 && text.startsWith("/")) {
637
+ const [cmd] = matches[Math.max(0, Math.min(this.paletteIdx, matches.length - 1))];
638
+ if (cmd.endsWith(" ")) {
639
+ // argument-taking command: fill the input and wait for arguments
640
+ // (the palette closes once the line contains a space; a second
641
+ // Enter then submits as typed)
642
+ this.inputGlyphs = [...cmd];
643
+ this.cursor = this.inputGlyphs.length;
644
+ this.paletteIdx = 0;
645
+ this.paint();
646
+ return;
647
+ }
648
+ text = cmd.trimEnd();
649
+ }
650
+
651
+ this.inputGlyphs = []; this.cursor = 0; this.histIdx = -1; this.paletteIdx = 0;
652
+ this.scrollOff = 0; // submitting a turn snaps back to the tail to watch the reply
653
+ if (text) { this.history.unshift(text); if (this.history.length > 200) this.history.pop(); }
654
+ const r = this.pendingResolve;
655
+ this.pendingResolve = null;
656
+ this.paint();
657
+ if (r) r(text);
658
+ return;
659
+ }
660
+
661
+ const paletteOpen = this.paletteMatches().length > 0 && this.inputGlyphs[0] === "/";
662
+
663
+ if (name === "up") {
664
+ if (paletteOpen) { this.paletteIdx = Math.max(0, this.paletteIdx - 1); }
665
+ else if (this.histIdx < this.history.length - 1) {
666
+ if (this.histIdx === -1) this.histStash = this.inputGlyphs.join("");
667
+ this.histIdx++;
668
+ this.inputGlyphs = [...this.history[this.histIdx]]; this.cursor = this.inputGlyphs.length;
669
+ }
670
+ this.paint(); return;
671
+ }
672
+ if (name === "down") {
673
+ if (paletteOpen) { this.paletteIdx = Math.min(this.paletteMatches().length - 1, this.paletteIdx + 1); }
674
+ else if (this.histIdx >= 0) {
675
+ this.histIdx--;
676
+ this.inputGlyphs = [...(this.histIdx === -1 ? this.histStash : this.history[this.histIdx])];
677
+ this.cursor = this.inputGlyphs.length;
678
+ }
679
+ this.paint(); return;
680
+ }
681
+ if (name === "tab" && key?.shift) { this.onModeCycle?.(); this.paint(); return; }
682
+ if (name === "tab") {
683
+ if (paletteOpen) {
684
+ const m = this.paletteMatches();
685
+ const pick = m[Math.min(this.paletteIdx, m.length - 1)];
686
+ if (pick) { this.inputGlyphs = [...pick[0].trimEnd()]; this.cursor = this.inputGlyphs.length; }
687
+ }
688
+ this.paint(); return;
689
+ }
690
+ if (name === "escape") {
691
+ // Esc closes the palette by clearing the slash input; otherwise it
692
+ // just resets selection / jumps back to the tail.
693
+ if (paletteOpen) { this.inputGlyphs = []; this.cursor = 0; }
694
+ this.paletteIdx = 0; this.scrollOff = 0; this.paint(); return;
695
+ }
696
+ if (name === "backspace") {
697
+ if (this.cursor > 0) { this.inputGlyphs.splice(this.cursor - 1, 1); this.cursor--; }
698
+ this.paletteIdx = 0; // filter changed — selection restarts at the top
699
+ this.paint(); return;
700
+ }
701
+ if (name === "delete") { if (this.cursor < this.inputGlyphs.length) this.inputGlyphs.splice(this.cursor, 1); this.paint(); return; }
702
+ if (name === "left") { if (this.cursor > 0) this.cursor--; this.paint(); return; }
703
+ if (name === "right") { if (this.cursor < this.inputGlyphs.length) this.cursor++; this.paint(); return; }
704
+ if (name === "home") { this.cursor = 0; this.paint(); return; }
705
+ if (name === "end") { this.cursor = this.inputGlyphs.length; this.paint(); return; }
706
+ if (key?.ctrl && name === "a") { this.cursor = 0; this.paint(); return; }
707
+ if (key?.ctrl && name === "e") { this.cursor = this.inputGlyphs.length; this.paint(); return; }
708
+ if (key?.ctrl && name === "u") { this.inputGlyphs.splice(0, this.cursor); this.cursor = 0; this.paint(); return; }
709
+ if (key?.ctrl && name === "w") {
710
+ let i = this.cursor;
711
+ while (i > 0 && this.inputGlyphs[i - 1] === " ") i--;
712
+ while (i > 0 && this.inputGlyphs[i - 1] !== " ") i--;
713
+ this.inputGlyphs.splice(i, this.cursor - i); this.cursor = i;
714
+ this.paint(); return;
715
+ }
716
+ if (key?.ctrl && name === "l") { this.clearViewport(); return; }
717
+
718
+ if (str && !key?.ctrl && !key?.meta) {
719
+ const glyphs = [...str].filter((c) => c >= " " || charWidth(c.codePointAt(0)!) > 0);
720
+ if (glyphs.length) {
721
+ this.inputGlyphs.splice(this.cursor, 0, ...glyphs);
722
+ this.cursor += glyphs.length;
723
+ this.histIdx = -1;
724
+ this.paletteIdx = 0; // filter changed — selection restarts at the top
725
+ this.paint();
726
+ }
727
+ }
728
+ }
729
+
730
+ private handleSigint() {
731
+ const now = Date.now();
732
+ if (this.busy && this.onInterrupt) {
733
+ this.onInterrupt();
734
+ return;
735
+ }
736
+ if (now - this.sigintAt < 1500) {
737
+ this.destroy();
738
+ process.stdout.write(chalk.dim(" 再会。\n"));
739
+ process.exit(0);
740
+ }
741
+ this.sigintAt = now;
742
+ this.flash("再按一次 Ctrl-C 退出");
743
+ }
744
+
745
+ private flashHint = "";
746
+
747
+ private paletteMatches(): [string, string][] {
748
+ const l = this.inputGlyphs.join("");
749
+ if (!l.startsWith("/") || l.includes(" ")) return [];
750
+ return [...SLASH_COMMANDS, ...this.extraCommands].filter(([c]) => c.trimEnd().startsWith(l));
751
+ }
752
+
753
+ /* ── geometry ── */
754
+
755
+ private cols(): number { return Math.max(40, this.out.columns || 80); }
756
+ private rows(): number { return Math.max(12, this.out.rows || 24); }
757
+ // header(1) + sky(2) + body + divider(1) + input(1) + bottom(1) = rows
758
+ private bodyH(): number { return this.rows() - SKY_H - 4; }
759
+
760
+ private clampScroll() {
761
+ const total = this.viewportLines().length;
762
+ const maxOff = Math.max(0, total - this.bodyH());
763
+ this.scrollOff = Math.max(0, Math.min(this.scrollOff, maxOff));
764
+ }
765
+
766
+ private invalidateWraps() { for (const b of this.blocks) b.cache = undefined; }
767
+
768
+ /* ── frame composition ── */
769
+
770
+ private frame() {
771
+ this.tick++;
772
+ const animate = this.busy || this.orch.active;
773
+ // advance shuttles
774
+ if (this.orch.active) {
775
+ for (const [a, x] of this.orch.shuttleX) this.orch.shuttleX.set(a, x + 1.3);
776
+ }
777
+ // ink "dries": the bleed tail shrinks even when no new tokens arrive
778
+ if (this.openBlock && this.bleedLen > 0 && this.tick % 2 === 0) this.bleedLen = Math.max(0, this.bleedLen - 2);
779
+ if (animate || this.tick % 5 === 0) {
780
+ const t = agentTheme(this.agentName);
781
+ this.sky.step(t.motion, this.tick);
782
+ this.paint();
783
+ }
784
+ }
785
+
786
+ private viewportCache: { lines: string[]; key: string } | null = null;
787
+
788
+ private viewportLines(): string[] {
789
+ const w = this.viewW();
790
+ // the open block's cursor pulse + bleed tail animate with the clock
791
+ const anim = this.openBlock ? `|b${this.bleedLen}|t${this.tick & 7}` : "";
792
+ const key = this.blocks.map((b) => b.version).join(",") + `|${w}|${this.blocks.length}` + anim;
793
+ if (this.viewportCache && this.viewportCache.key === key) return this.viewportCache.lines;
794
+ const lines: string[] = [];
795
+ for (const b of this.blocks) {
796
+ if (b.kind === "blank") { lines.push(""); continue; }
797
+ if (b.kind === "line") { lines.push(cutVisual(b.text, w)); continue; }
798
+ if (!b.cache || b.cache.width !== w || b.cache.version !== b.version) {
799
+ const wrapped = wrapPlain(b.text, b.head ? w - visualWidth(b.head) : w);
800
+ b.cache = { width: w, version: b.version, lines: wrapped };
801
+ }
802
+ const style = b.style ?? ((s: string) => s);
803
+ b.cache.lines.forEach((ln, i) => {
804
+ const head = b.head ? (i === 0 ? b.head : " ".repeat(visualWidth(b.head))) : "";
805
+ let body = style(ln);
806
+ if (b.open && i === b.cache!.lines.length - 1) {
807
+ // ink-bleed: the freshest glyphs render faint, "drying" into full ink
808
+ const glyphs = [...ln];
809
+ const bleed = Math.min(this.bleedLen, glyphs.length);
810
+ if (bleed > 0) {
811
+ const headPart = glyphs.slice(0, glyphs.length - bleed).join("");
812
+ const tailPart = glyphs.slice(glyphs.length - bleed).join("");
813
+ body = style(headPart) + chalk.hex(PALETTE.inkLight)(tailPart);
814
+ }
815
+ body += this.tick % 8 < 4 ? chalk.hex(agentTheme(this.agentName).hex)("▍") : chalk.dim("▏");
816
+ }
817
+ lines.push(head + body);
818
+ });
819
+ }
820
+ this.viewportCache = { lines, key };
821
+ return lines;
822
+ }
823
+
824
+ // borders(2) + rail + rail-border(1) + gutter(1)
825
+ private viewW(): number { return this.cols() - 2 - RAIL_W - 2; }
826
+
827
+ private railLines(h: number): string[] {
828
+ const out: string[] = [];
829
+ const W = RAIL_W;
830
+ out.push("");
831
+ for (const name of AGENT_ORDER) {
832
+ const t = agentTheme(name);
833
+ const active = name === this.agentName;
834
+ const tally = this.orch.tally(name);
835
+ let marker: string;
836
+ if (tally.run) marker = this.tick % 2 ? chalk.hex(t.hex)(t.symbol) : chalk.hex(t.hex).dim(t.symbol);
837
+ else marker = active ? chalk.hex(t.hex)("●") : chalk.hex(PALETTE.inkFaint)("·");
838
+ let badge = "";
839
+ if (tally.ok) badge += chalk.hex("#3a7a6e")(` ✓${tally.ok}`);
840
+ if (tally.fail) badge += chalk.hex("#b3342d")(` ✗${tally.fail}`);
841
+ const label = active ? chalk.bold.hex(t.hex)(`${t.kanji} ${t.name}`) : chalk.hex(t.hex).dim(`${t.kanji} ${t.name}`);
842
+ out.push(padAnsi(` ${marker} ${label}${badge}`, W));
843
+ }
844
+ out.push(chalk.hex(PALETTE.inkFaint)(" " + "╌".repeat(W - 2)));
845
+ const t = agentTheme(this.agentName);
846
+ for (const ln of wrapPlain(t.poem, W - 2).slice(0, 2)) {
847
+ out.push(" " + chalk.hex(PALETTE.inkLight).italic(ln));
848
+ }
849
+ out.push(" " + chalk.hex(PALETTE.inkLight).dim(t.pigment));
850
+ if (this.orch.active) {
851
+ const p = this.orch.progress();
852
+ out.push("");
853
+ out.push(" " + chalk.hex(t.hex)(`織 ${p.done}/${p.total}`) + chalk.dim(" 梭"));
854
+ }
855
+ while (out.length < h) out.push("");
856
+ return out.slice(0, h);
857
+ }
858
+
859
+ /** Compose and flush a frame. Returns the composed rows (used by tests). */
860
+ paint(): string[] {
861
+ if (this.destroyed) return [];
862
+ const cols = this.cols();
863
+ const rows = this.rows();
864
+ const innerW = cols - 2;
865
+ const t = agentTheme(this.agentName);
866
+ const frame: string[] = [];
867
+ const faint = chalk.hex(PALETTE.inkFaint);
868
+ const B = (s: string) => faint(s);
869
+
870
+ if (cols < 60 || rows < 14) {
871
+ const small = [chalk.yellow(" 窗口太小 · 请放大终端 (≥60×14) ")];
872
+ this.screen.flush(small, null);
873
+ return small;
874
+ }
875
+
876
+ // ── header: title + seal ──
877
+ {
878
+ const seal = chalk.bgHex(t.hex).hex(PALETTE.paper).bold(` ${t.kanji} `);
879
+ const title = chalk.bold(" 天空织机 ") + chalk.dim("Skyloom ");
880
+ // ┌─ title ───…─ seal ─┐ → 2 + w(title) + fill + 4 + 2 = cols
881
+ const fill = innerW - visualWidth(title) - 6;
882
+ frame.push(B("┌─") + title + B("─".repeat(Math.max(0, fill))) + seal + B("─┐"));
883
+ }
884
+
885
+ // ── sky band ──
886
+ {
887
+ const shuttles = this.orch.active
888
+ ? this.orch.runningAgents().map((a, i) => {
889
+ const th = agentTheme(a);
890
+ return { symbol: th.symbol, hex: th.hex, x: (this.orch.shuttleX.get(a) || 0) % innerW, row: i };
891
+ })
892
+ : [];
893
+ const skyRows = this.sky.render(innerW, t.motion, t.symbol, t.hex, this.tick, shuttles);
894
+ const mountain = mountainRow(innerW, this.turns);
895
+ frame.push(B("│") + padAnsi(skyRows[0], innerW) + B("│"));
896
+ // mountain sits behind the lower particle row: particles overlay where present
897
+ frame.push(B("│") + overlay(mountain, skyRows[1], innerW) + B("│"));
898
+ }
899
+
900
+ // ── body: rail │ viewport ──
901
+ const bodyH = this.bodyH();
902
+ const rail = this.railLines(bodyH);
903
+ const view = this.viewportLines();
904
+ this.clampScroll();
905
+ const start = Math.max(0, view.length - bodyH - this.scrollOff);
906
+ const visible = view.slice(start, start + bodyH);
907
+ for (let i = 0; i < bodyH; i++) {
908
+ const left = padAnsi(rail[i] ?? "", RAIL_W);
909
+ const right = padAnsi(visible[i] ?? "", this.viewW());
910
+ frame.push(B("│") + left + B("│") + " " + right + B("│"));
911
+ }
912
+
913
+ // ── status divider ──
914
+ {
915
+ let leftLabel = "";
916
+ if (this.modal) leftLabel = "";
917
+ else if (this.busy && this.busyLabel) {
918
+ const dots = ["· ", "·· ", "···", " ··", " ·", " "][this.tick % 6];
919
+ leftLabel = ` ${chalk.hex(t.hex)(t.symbol)} ${chalk.dim(this.busyLabel + " " + dots)} `;
920
+ } else if (this.flashHint) leftLabel = " " + chalk.yellow(this.flashHint) + " ";
921
+ else if (this.scrollOff > 0) leftLabel = " " + chalk.dim(`↑ 回看中 · Esc 回到末尾`) + " ";
922
+ else if (this.modeBadge) leftLabel = " " + this.modeBadge + " ";
923
+ const right = this.statusRight();
924
+ const rightLabel = right ? ` ${right} ` : "";
925
+ const fill = innerW - visualWidth(leftLabel) - visualWidth(rightLabel);
926
+ frame.push(B("├") + leftLabel + B("─".repeat(Math.max(0, fill))) + rightLabel + B("┤"));
927
+ }
928
+
929
+ // ── palette overlay (replaces tail viewport rows visually — drawn over input-adjacent rows) ──
930
+ // (kept simple: palette renders inside the viewport's final rows via paint order below)
931
+
932
+ // ── input row ──
933
+ let cursorPos: { row: number; col: number } | null = null;
934
+ {
935
+ let content: string;
936
+ if (this.modal) {
937
+ content = " " + chalk.yellow("⚠ ") + cutVisual(this.modal.text, innerW - 14) + chalk.bold(" 允许? ") + chalk.dim("[y/N]");
938
+ cursorPos = { row: rows - 2, col: Math.min(innerW, visualWidth(content) + 1) };
939
+ } else {
940
+ const promptStr = chalk.hex(t.hex)(` ${t.symbol} `) + chalk.hex(PALETTE.inkLight)("❯ ");
941
+ const promptW = visualWidth(promptStr);
942
+ const avail = innerW - promptW - 1;
943
+ // horizontal scroll window around the cursor
944
+ const glyphs = this.inputGlyphs;
945
+ let beforeW = 0;
946
+ for (let i = 0; i < this.cursor; i++) beforeW += charWidth(glyphs[i].codePointAt(0)!);
947
+ let startIdx = 0, skipW = 0;
948
+ while (beforeW - skipW > avail - 2 && startIdx < glyphs.length) {
949
+ skipW += charWidth(glyphs[startIdx].codePointAt(0)!);
950
+ startIdx++;
951
+ }
952
+ let shown = "", shownW = 0, cursorCol = promptW + (beforeW - skipW);
953
+ for (let i = startIdx; i < glyphs.length; i++) {
954
+ const cw = charWidth(glyphs[i].codePointAt(0)!);
955
+ if (shownW + cw > avail) break;
956
+ shown += glyphs[i]; shownW += cw;
957
+ }
958
+ content = promptStr + shown;
959
+ cursorPos = { row: rows - 2, col: 1 + cursorCol };
960
+ }
961
+ frame.push(B("│") + padAnsi(content, innerW) + B("│"));
962
+ }
963
+
964
+ // ── bottom border with hints ──
965
+ {
966
+ const paletteUp = this.paletteMatches().length > 0 && this.inputGlyphs[0] === "/";
967
+ const hint = this.busy
968
+ ? " Ctrl-C 中断本轮 "
969
+ : paletteUp
970
+ ? " ↑↓ 选命令 · Enter 执行 · Tab 补全 · Esc 收起 "
971
+ : " / 命令 · 滚轮/PgUp 回看 · Shift+Tab 切模式 · Ctrl-C 退出 ";
972
+ // └─ hint ───…┘ → 2 + w(hint) + fill + 1 = cols
973
+ const fill = innerW - visualWidth(hint) - 1;
974
+ frame.push(B("└─") + chalk.dim(hint) + B("─".repeat(Math.max(0, fill)) + "┘"));
975
+ }
976
+
977
+ // ── slash palette: overlay onto the rows just above the divider ──
978
+ const matches = this.paletteMatches();
979
+ if (matches.length > 0 && this.inputGlyphs[0] === "/" && !this.modal) {
980
+ const maxShow = Math.min(8, bodyH - 1);
981
+ this.paletteIdx = Math.max(0, Math.min(this.paletteIdx, matches.length - 1));
982
+ // scroll window that keeps the ↑↓ selection visible
983
+ const start = Math.max(0, Math.min(this.paletteIdx - maxShow + 1, matches.length - maxShow));
984
+ const show = matches.slice(start, start + maxShow);
985
+ const baseRow = 1 + SKY_H + bodyH - show.length; // first overlay row index in frame
986
+ show.forEach(([cmd, desc], i) => {
987
+ const sel = start + i === this.paletteIdx;
988
+ const agentCmd = ["/fog", "/rain", "/frost", "/snow", "/dew", "/fair"].includes(cmd.trim());
989
+ const color = agentCmd ? chalk.hex(agentTheme(cmd.trim().slice(1)).hex) : chalk.hex(PALETTE.inkLight);
990
+ const mark = sel ? chalk.hex(t.hex)(" ▸ ") : " ";
991
+ const counter = sel && matches.length > maxShow ? chalk.dim(` ${this.paletteIdx + 1}/${matches.length}`) : "";
992
+ const lineStr = mark + (sel ? chalk.bold(color(cmd.padEnd(11))) : color(cmd.padEnd(11))) + chalk.dim(cutVisual(desc, this.viewW() - 22)) + counter;
993
+ const row = baseRow + i;
994
+ // Reuse the rail already composed for the body rather than rebuilding it
995
+ // per overlay row (railLines runs wrapPlain etc. — wasteful at frame rate).
996
+ frame[row] = B("│") + padAnsi(rail[row - 1 - SKY_H] ?? "", RAIL_W) + B("│") + " " + padAnsi(lineStr, this.viewW()) + B("│");
997
+ });
998
+ }
999
+
1000
+ this.screen.flush(frame, cursorPos);
1001
+ return frame;
1002
+ }
1003
+ }
1004
+
1005
+ /** Overlay `top` onto `base` (top's non-space glyphs win), to a fixed width. */
1006
+ export function overlay(base: string, top: string, width: number): string {
1007
+ // Both strings are styled; walk them in parallel by visual column.
1008
+ const cells = (s: string): string[] => {
1009
+ const out: string[] = [];
1010
+ let i = 0;
1011
+ let pending = "";
1012
+ while (i < s.length && out.length < width * 2) {
1013
+ if (s[i] === ESC) {
1014
+ const m = ANSI_RE.exec(s.slice(i));
1015
+ if (m && m.index === 0) { pending += m[0]; i += m[0].length; continue; }
1016
+ }
1017
+ const cp = s.codePointAt(i)!;
1018
+ const ch = String.fromCodePoint(cp);
1019
+ const cw = charWidth(cp);
1020
+ out.push(pending + ch);
1021
+ pending = "";
1022
+ if (cw === 2) out.push(""); // wide glyph occupies two cells
1023
+ i += ch.length;
1024
+ }
1025
+ return out;
1026
+ };
1027
+ const b = cells(base);
1028
+ const t = cells(top);
1029
+ let res = "";
1030
+ for (let x = 0; x < width; x++) {
1031
+ const tc = t[x];
1032
+ const bc = b[x];
1033
+ const topVisible = tc !== undefined && tc.replace(/\x1b\[[0-9;]*m/g, "") !== " " && tc !== "";
1034
+ if (topVisible) res += tc + "\x1b[0m";
1035
+ else if (tc === "") continue; // second cell of a wide top glyph
1036
+ else if (bc !== undefined && bc !== "") res += bc + "\x1b[0m";
1037
+ else if (bc === "") continue;
1038
+ else res += " ";
1039
+ }
1040
+ return padAnsi(res, width);
1041
+ }