@zaly/tui 0.0.4

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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/assets/schemas/theme.schema.json +899 -0
  4. package/assets/themes/LICENSE-tokyonight.txt +201 -0
  5. package/assets/themes/NOTICES.md +69 -0
  6. package/assets/themes/ayu-dark.json +119 -0
  7. package/assets/themes/catppuccin-frappe.json +124 -0
  8. package/assets/themes/catppuccin-latte.json +124 -0
  9. package/assets/themes/catppuccin-macchiato.json +124 -0
  10. package/assets/themes/catppuccin-mocha.json +124 -0
  11. package/assets/themes/dracula-soft.json +119 -0
  12. package/assets/themes/dracula.json +119 -0
  13. package/assets/themes/everforest-dark.json +118 -0
  14. package/assets/themes/everforest-light.json +118 -0
  15. package/assets/themes/github-dark.json +123 -0
  16. package/assets/themes/github-light.json +123 -0
  17. package/assets/themes/gruvbox-dark-medium.json +112 -0
  18. package/assets/themes/kanagawa-dragon.json +115 -0
  19. package/assets/themes/kanagawa-wave.json +115 -0
  20. package/assets/themes/material-theme-palenight.json +114 -0
  21. package/assets/themes/min-dark.json +55 -0
  22. package/assets/themes/min-light.json +59 -0
  23. package/assets/themes/monokai.json +116 -0
  24. package/assets/themes/night-owl.json +116 -0
  25. package/assets/themes/nord.json +101 -0
  26. package/assets/themes/one-dark-pro.json +110 -0
  27. package/assets/themes/one-light.json +108 -0
  28. package/assets/themes/poimandres.json +123 -0
  29. package/assets/themes/rose-pine.json +98 -0
  30. package/assets/themes/solarized-dark.json +116 -0
  31. package/assets/themes/solarized-light.json +116 -0
  32. package/assets/themes/synthwave-84.json +100 -0
  33. package/assets/themes/tokyonight-day.json +157 -0
  34. package/assets/themes/tokyonight-moon.json +157 -0
  35. package/assets/themes/tokyonight-night.json +157 -0
  36. package/assets/themes/tokyonight-storm.json +157 -0
  37. package/assets/themes/vesper.json +103 -0
  38. package/assets/themes/vitesse-dark.json +126 -0
  39. package/assets/themes/vitesse-light.json +126 -0
  40. package/dist/ansi-Clq0ozyA.mjs +206 -0
  41. package/dist/ansi-Clq0ozyA.mjs.map +1 -0
  42. package/dist/ansi.d.mts +48 -0
  43. package/dist/ansi.d.mts.map +1 -0
  44. package/dist/ansi.mjs +2 -0
  45. package/dist/autocomplete-Dfw-yQDa.d.mts +100 -0
  46. package/dist/autocomplete-Dfw-yQDa.d.mts.map +1 -0
  47. package/dist/box-DyaByEiG.mjs +386 -0
  48. package/dist/box-DyaByEiG.mjs.map +1 -0
  49. package/dist/builder-D49ECBEf.mjs +385 -0
  50. package/dist/builder-D49ECBEf.mjs.map +1 -0
  51. package/dist/callbacks-CNjGNxVw.mjs +279 -0
  52. package/dist/callbacks-CNjGNxVw.mjs.map +1 -0
  53. package/dist/client-D6C_uczF.mjs +142 -0
  54. package/dist/client-D6C_uczF.mjs.map +1 -0
  55. package/dist/clipboard.d.mts +104 -0
  56. package/dist/clipboard.d.mts.map +1 -0
  57. package/dist/clipboard.mjs +404 -0
  58. package/dist/clipboard.mjs.map +1 -0
  59. package/dist/flex-Bguo-fUe.mjs +202 -0
  60. package/dist/flex-Bguo-fUe.mjs.map +1 -0
  61. package/dist/image-Cm5T-uw-.mjs +108 -0
  62. package/dist/image-Cm5T-uw-.mjs.map +1 -0
  63. package/dist/image.d.mts +123 -0
  64. package/dist/image.d.mts.map +1 -0
  65. package/dist/image.mjs +2 -0
  66. package/dist/index.d.mts +64 -0
  67. package/dist/index.d.mts.map +1 -0
  68. package/dist/index.mjs +222 -0
  69. package/dist/index.mjs.map +1 -0
  70. package/dist/input-B5Vy-iaa.d.mts +141 -0
  71. package/dist/input-B5Vy-iaa.d.mts.map +1 -0
  72. package/dist/inspect-D1d9t-ni.mjs +101 -0
  73. package/dist/inspect-D1d9t-ni.mjs.map +1 -0
  74. package/dist/inspect-D6FW_QUo.d.mts +67 -0
  75. package/dist/inspect-D6FW_QUo.d.mts.map +1 -0
  76. package/dist/kitty-JbCJC0iQ.mjs +673 -0
  77. package/dist/kitty-JbCJC0iQ.mjs.map +1 -0
  78. package/dist/markdown.d.mts +24 -0
  79. package/dist/markdown.d.mts.map +1 -0
  80. package/dist/markdown.mjs +2 -0
  81. package/dist/md.bun.d.mts +14 -0
  82. package/dist/md.bun.d.mts.map +1 -0
  83. package/dist/md.bun.mjs +28 -0
  84. package/dist/md.bun.mjs.map +1 -0
  85. package/dist/md.node.d.mts +14 -0
  86. package/dist/md.node.d.mts.map +1 -0
  87. package/dist/md.node.mjs +126 -0
  88. package/dist/md.node.mjs.map +1 -0
  89. package/dist/node-BZ7f6HzU.mjs +316 -0
  90. package/dist/node-BZ7f6HzU.mjs.map +1 -0
  91. package/dist/picker-D8OLb6kZ.d.mts +36 -0
  92. package/dist/picker-D8OLb6kZ.d.mts.map +1 -0
  93. package/dist/picker-ndVVt6qF.mjs +621 -0
  94. package/dist/picker-ndVVt6qF.mjs.map +1 -0
  95. package/dist/queries-DH3bj74K.d.mts +1963 -0
  96. package/dist/queries-DH3bj74K.d.mts.map +1 -0
  97. package/dist/reactive-f7NpAi69.mjs +623 -0
  98. package/dist/reactive-f7NpAi69.mjs.map +1 -0
  99. package/dist/registry-U654j61Y.mjs +356 -0
  100. package/dist/registry-U654j61Y.mjs.map +1 -0
  101. package/dist/render-DxVLqqB9.mjs +74 -0
  102. package/dist/render-DxVLqqB9.mjs.map +1 -0
  103. package/dist/renderer-CdLCkJB-.mjs +42 -0
  104. package/dist/renderer-CdLCkJB-.mjs.map +1 -0
  105. package/dist/renderer-CxL4xbkl.mjs +2644 -0
  106. package/dist/renderer-CxL4xbkl.mjs.map +1 -0
  107. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  108. package/dist/search-FvGnswRl.d.mts +49 -0
  109. package/dist/search-FvGnswRl.d.mts.map +1 -0
  110. package/dist/select-CbmAZdpt.d.mts +116 -0
  111. package/dist/select-CbmAZdpt.d.mts.map +1 -0
  112. package/dist/services/logger.d.mts +2 -0
  113. package/dist/services/logger.mjs +71 -0
  114. package/dist/services/logger.mjs.map +1 -0
  115. package/dist/services/notifier.d.mts +17 -0
  116. package/dist/services/notifier.d.mts.map +1 -0
  117. package/dist/services/notifier.mjs +71 -0
  118. package/dist/services/notifier.mjs.map +1 -0
  119. package/dist/services/picker.d.mts +36 -0
  120. package/dist/services/picker.d.mts.map +1 -0
  121. package/dist/services/picker.mjs +137 -0
  122. package/dist/services/picker.mjs.map +1 -0
  123. package/dist/services/prompt.d.mts +30 -0
  124. package/dist/services/prompt.d.mts.map +1 -0
  125. package/dist/services/prompt.mjs +83 -0
  126. package/dist/services/prompt.mjs.map +1 -0
  127. package/dist/shiki-BrjVUkeh.mjs +423 -0
  128. package/dist/shiki-BrjVUkeh.mjs.map +1 -0
  129. package/dist/size-D8JogYLy.mjs +34 -0
  130. package/dist/size-D8JogYLy.mjs.map +1 -0
  131. package/dist/style.d.mts +10 -0
  132. package/dist/style.d.mts.map +1 -0
  133. package/dist/style.mjs +4 -0
  134. package/dist/text.d.mts +33 -0
  135. package/dist/text.d.mts.map +1 -0
  136. package/dist/text.mjs +111 -0
  137. package/dist/text.mjs.map +1 -0
  138. package/dist/theme.config-CU3B50HN.mjs +832 -0
  139. package/dist/theme.config-CU3B50HN.mjs.map +1 -0
  140. package/dist/themes.d.mts +113 -0
  141. package/dist/themes.d.mts.map +1 -0
  142. package/dist/themes.mjs +2 -0
  143. package/dist/tree-C28QOVIK.d.mts +23 -0
  144. package/dist/tree-C28QOVIK.d.mts.map +1 -0
  145. package/dist/types-B9Eh7k2v.d.mts +212 -0
  146. package/dist/types-B9Eh7k2v.d.mts.map +1 -0
  147. package/dist/types-D3jk1rtk.d.mts +140 -0
  148. package/dist/types-D3jk1rtk.d.mts.map +1 -0
  149. package/dist/utils-3M4D_AXZ.mjs +40 -0
  150. package/dist/utils-3M4D_AXZ.mjs.map +1 -0
  151. package/dist/widgets/autocomplete.d.mts +2 -0
  152. package/dist/widgets/autocomplete.mjs +165 -0
  153. package/dist/widgets/autocomplete.mjs.map +1 -0
  154. package/dist/widgets/box.d.mts +2 -0
  155. package/dist/widgets/box.mjs +2 -0
  156. package/dist/widgets/code.d.mts +48 -0
  157. package/dist/widgets/code.d.mts.map +1 -0
  158. package/dist/widgets/code.mjs +59 -0
  159. package/dist/widgets/code.mjs.map +1 -0
  160. package/dist/widgets/completions/actions.d.mts +41 -0
  161. package/dist/widgets/completions/actions.d.mts.map +1 -0
  162. package/dist/widgets/completions/actions.mjs +48 -0
  163. package/dist/widgets/completions/actions.mjs.map +1 -0
  164. package/dist/widgets/completions/files.d.mts +29 -0
  165. package/dist/widgets/completions/files.d.mts.map +1 -0
  166. package/dist/widgets/completions/files.mjs +42 -0
  167. package/dist/widgets/completions/files.mjs.map +1 -0
  168. package/dist/widgets/completions/github.d.mts +64 -0
  169. package/dist/widgets/completions/github.d.mts.map +1 -0
  170. package/dist/widgets/completions/github.mjs +137 -0
  171. package/dist/widgets/completions/github.mjs.map +1 -0
  172. package/dist/widgets/completions/index.d.mts +4 -0
  173. package/dist/widgets/completions/index.mjs +4 -0
  174. package/dist/widgets/diff.d.mts +57 -0
  175. package/dist/widgets/diff.d.mts.map +1 -0
  176. package/dist/widgets/diff.mjs +199 -0
  177. package/dist/widgets/diff.mjs.map +1 -0
  178. package/dist/widgets/divider.d.mts +11 -0
  179. package/dist/widgets/divider.d.mts.map +1 -0
  180. package/dist/widgets/divider.mjs +9 -0
  181. package/dist/widgets/divider.mjs.map +1 -0
  182. package/dist/widgets/image.d.mts +37 -0
  183. package/dist/widgets/image.d.mts.map +1 -0
  184. package/dist/widgets/image.mjs +77 -0
  185. package/dist/widgets/image.mjs.map +1 -0
  186. package/dist/widgets/input.d.mts +2 -0
  187. package/dist/widgets/input.mjs +497 -0
  188. package/dist/widgets/input.mjs.map +1 -0
  189. package/dist/widgets/log.d.mts +2 -0
  190. package/dist/widgets/log.mjs +130 -0
  191. package/dist/widgets/log.mjs.map +1 -0
  192. package/dist/widgets/markdown.d.mts +45 -0
  193. package/dist/widgets/markdown.d.mts.map +1 -0
  194. package/dist/widgets/markdown.mjs +124 -0
  195. package/dist/widgets/markdown.mjs.map +1 -0
  196. package/dist/widgets/overlay.d.mts +2 -0
  197. package/dist/widgets/overlay.mjs +18 -0
  198. package/dist/widgets/overlay.mjs.map +1 -0
  199. package/dist/widgets/picker.d.mts +2 -0
  200. package/dist/widgets/picker.mjs +2 -0
  201. package/dist/widgets/progress.d.mts +48 -0
  202. package/dist/widgets/progress.d.mts.map +1 -0
  203. package/dist/widgets/progress.mjs +48 -0
  204. package/dist/widgets/progress.mjs.map +1 -0
  205. package/dist/widgets/select.d.mts +2 -0
  206. package/dist/widgets/select.mjs +195 -0
  207. package/dist/widgets/select.mjs.map +1 -0
  208. package/dist/widgets/show.d.mts +55 -0
  209. package/dist/widgets/show.d.mts.map +1 -0
  210. package/dist/widgets/show.mjs +85 -0
  211. package/dist/widgets/show.mjs.map +1 -0
  212. package/dist/widgets/spinner.d.mts +65 -0
  213. package/dist/widgets/spinner.d.mts.map +1 -0
  214. package/dist/widgets/spinner.mjs +147 -0
  215. package/dist/widgets/spinner.mjs.map +1 -0
  216. package/dist/widgets/text.d.mts +2 -0
  217. package/dist/widgets/text.mjs +32 -0
  218. package/dist/widgets/text.mjs.map +1 -0
  219. package/dist/widgets/tree.d.mts +2 -0
  220. package/dist/widgets/tree.mjs +73 -0
  221. package/dist/widgets/tree.mjs.map +1 -0
  222. package/dist/widgets/widget.d.mts +49 -0
  223. package/dist/widgets/widget.d.mts.map +1 -0
  224. package/dist/widgets/widget.mjs +40 -0
  225. package/dist/widgets/widget.mjs.map +1 -0
  226. package/dist/worker-DjHuQSbY.mjs +27 -0
  227. package/dist/worker-DjHuQSbY.mjs.map +1 -0
  228. package/package.json +117 -0
@@ -0,0 +1,2644 @@
1
+ import { C as useActiveOwner, E as withOwner, S as unwrap, _ as peek, d as createSuspenseBoundary, h as memo, l as createRoot, n as SuspenseContext, o as createNode, v as provideContext, x as untrack, y as signal } from "./reactive-f7NpAi69.mjs";
2
+ import { t as defaultTheme } from "./registry-U654j61Y.mjs";
3
+ import { s as stripAnsiBg } from "./ansi-Clq0ozyA.mjs";
4
+ import { t as styleBuilder } from "./builder-D49ECBEf.mjs";
5
+ import { n as box } from "./box-DyaByEiG.mjs";
6
+ import { r as createCtx, t as RenderContext } from "./render-DxVLqqB9.mjs";
7
+ import { TuiReporter } from "./services/logger.mjs";
8
+ import { o as bumpPlacements } from "./kitty-JbCJC0iQ.mjs";
9
+ import { TerminalQueries, canonical } from "./index.mjs";
10
+ import { Emitter } from "@zaly/shared";
11
+ import { sliceAnsi, stringWidth, stripAnsi } from "@zaly/shared/ansi";
12
+ import { inspect, isDeepStrictEqual } from "node:util";
13
+ import { Logger } from "@zaly/shared/logger";
14
+ import { throttle } from "@zaly/shared/throttle";
15
+ //#region src/input/actions.ts
16
+ function filterAction(action, filter) {
17
+ if (!action) return false;
18
+ if (!filter) return true;
19
+ if (filter.cmd && filter.cmd !== action.cmd) return false;
20
+ if (filter.id) {
21
+ if (typeof filter.id === "string" && filter.id !== action.id) return false;
22
+ if (filter.id instanceof RegExp && !filter.id.test(action.id)) return false;
23
+ }
24
+ if (filter.hidden !== void 0 && filter.hidden !== (action.hidden ?? false)) return false;
25
+ if (filter.filter && !filter.filter(action)) return false;
26
+ if (filter.source && filter.source !== action.source) return false;
27
+ return true;
28
+ }
29
+ /**
30
+ * Runtime registry of action metadata + impls.
31
+ *
32
+ * `register(entries)` merges new `ActionInfo` objects into the catalog
33
+ * by id — passing `{ "x": { keys: [...] } }` updates only the `keys`
34
+ * field of `x`, preserving existing `fn`/`desc`. That lets docs,
35
+ * bindings, and impls be registered in separate calls / from different
36
+ * layers (bundled defaults, app-level overrides, plugin contributions).
37
+ *
38
+ * `dispatch(id, ctx)` runs the action:
39
+ * - If the catalog entry has `fn`, it's called directly (global
40
+ * actions).
41
+ * - Otherwise, the focus chain is walked from `ctx.target` (default:
42
+ * the router's focused node) up to the root, and the first node
43
+ * with `actions[id]` handles it.
44
+ *
45
+ * `list()` returns the catalog for command palette / help enumeration.
46
+ */
47
+ var Actions = class extends Emitter {
48
+ #catalog = /* @__PURE__ */ new Map();
49
+ /** Optional focus-chain walker. Supplied by the Renderer so Actions
50
+ * doesn't have to know about the router directly. Returns the
51
+ * starting node (focused by default) so dispatch can walk up. */
52
+ #getTarget = () => void 0;
53
+ #logger;
54
+ #keymap = /* @__PURE__ */ new Map();
55
+ constructor(logger) {
56
+ super();
57
+ this.#logger = logger ?? new Logger();
58
+ this.on("change", () => this.#updateKeymap());
59
+ }
60
+ /** Internal — the Renderer wires this after construction. */
61
+ setTargetResolver(fn) {
62
+ this.#getTarget = fn;
63
+ }
64
+ bind(binding) {
65
+ const { id, keys, ...info } = binding;
66
+ return this.register({ [id]: {
67
+ ...info,
68
+ keys: (Array.isArray(keys) ? keys : [keys]).map((k) => canonical(k))
69
+ } });
70
+ }
71
+ /**
72
+ * Register or merge catalog entries. Values are shallow-merged into
73
+ * existing entries by id.
74
+ *
75
+ * When `opts.default` is `true`, the new entry is merged *before* the
76
+ * existing one, so it provides defaults.
77
+ *
78
+ * When `opts.default` is `false` (the default), the new entry is merged
79
+ * *after* the existing one, so it overrides prior values.
80
+ *
81
+ * Returns an unregister function that rolls this call back.
82
+ */
83
+ register(actions, opts = {}) {
84
+ const entries = Array.isArray(actions) ? Object.fromEntries(actions.map((a) => [a.id, a])) : actions;
85
+ const isDefault = opts.default ?? false;
86
+ const ids = Object.keys(entries);
87
+ const prior = /* @__PURE__ */ new Map();
88
+ for (const id of ids) prior.set(id, this.#catalog.get(id));
89
+ for (const [id, info] of Object.entries(entries)) {
90
+ info.keys = info.keys?.map((k) => canonical(k));
91
+ const existing = this.#catalog.get(id);
92
+ this.#catalog.set(id, isDefault ? {
93
+ ...info,
94
+ ...existing,
95
+ id
96
+ } : {
97
+ ...existing,
98
+ ...info,
99
+ id
100
+ });
101
+ }
102
+ this.emit("change");
103
+ return () => {
104
+ for (const id of ids) {
105
+ const before = prior.get(id);
106
+ if (before === void 0) this.#catalog.delete(id);
107
+ else this.#catalog.set(id, before);
108
+ }
109
+ this.emit("change");
110
+ };
111
+ }
112
+ /** Remove entries by id. */
113
+ delete(filter) {
114
+ const ids = Array.isArray(filter) ? filter : this.list(filter).map((a) => a.id);
115
+ let changed = false;
116
+ for (const id of ids) if (this.#catalog.delete(id)) changed = true;
117
+ if (changed) this.emit("change");
118
+ }
119
+ get(id) {
120
+ return this.#catalog.get(id);
121
+ }
122
+ list(opts) {
123
+ return [...this.#catalog.values()].filter((action) => opts ? filterAction(action, opts) : true);
124
+ }
125
+ find(opts) {
126
+ return this.list(opts)[0];
127
+ }
128
+ dispatch(idOrAction, partial = {}) {
129
+ const id = typeof idOrAction === "string" ? idOrAction : idOrAction.id;
130
+ const action = typeof idOrAction === "string" ? this.#catalog.get(id) : idOrAction;
131
+ const target = partial.target ?? this.#getTarget();
132
+ const source = partial.source ?? "programmatic";
133
+ if (action?.fn) {
134
+ const ctx = {
135
+ id,
136
+ source,
137
+ target,
138
+ ...partial
139
+ };
140
+ const fn = action.fn;
141
+ this.#logger.try(() => fn(ctx), {
142
+ id,
143
+ name: "dispatch",
144
+ source
145
+ });
146
+ return true;
147
+ }
148
+ for (let node = target; node !== void 0; node = node.parent) {
149
+ if (!node.isVisible() || !node.mounted) continue;
150
+ const entry = node.actions?.[id];
151
+ const fn = typeof entry === "function" ? entry : entry?.fn;
152
+ if (typeof fn === "function") {
153
+ const ctx = {
154
+ id,
155
+ node,
156
+ source,
157
+ target,
158
+ ...partial
159
+ };
160
+ this.#logger.try(() => fn(ctx), {
161
+ id,
162
+ name: "dispatch",
163
+ source
164
+ });
165
+ return true;
166
+ }
167
+ }
168
+ return false;
169
+ }
170
+ dispatchKey(routed, opts = {}) {
171
+ const actions = (this.#keymap.get(canonical(routed.pattern)) ?? []).map((id) => this.get(id)).filter((a) => filterAction(a)).toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
172
+ if (!actions.length) return false;
173
+ const nodeActions = opts.node ?? true ? actions.filter((a) => !a.fn) : [];
174
+ const globalActions = opts.global ?? true ? actions.filter((a) => a.fn) : [];
175
+ const target = this.#getTarget();
176
+ if (nodeActions.length) {
177
+ for (const t of target ? [...target.actionTargets, target] : []) for (const a of nodeActions) if (this.dispatch(a, {
178
+ key: routed,
179
+ source: "key",
180
+ target: t
181
+ })) return true;
182
+ }
183
+ for (const a of globalActions) if (this.dispatch(a, {
184
+ key: routed,
185
+ source: "key"
186
+ })) return true;
187
+ return false;
188
+ }
189
+ /**
190
+ * Build a `canonical-key → action-id[]` keymap from every catalog
191
+ * entry's `keys` field. Multiple actions can share the same default
192
+ * binding (e.g. both `input.submit` and `menu.select` default to
193
+ * `enter`) — the router's dispatch tries each candidate in order
194
+ * and the first whose handler is reachable on the focus chain
195
+ * wins. Registration order defines priority; later wins.
196
+ */
197
+ #updateKeymap() {
198
+ const out = /* @__PURE__ */ new Map();
199
+ for (const [id, info] of this.#catalog) {
200
+ if (!info.keys) continue;
201
+ for (const pattern of info.keys) {
202
+ const c = canonical(pattern);
203
+ const list = out.get(c) ?? [];
204
+ list.push(id);
205
+ out.set(c, list);
206
+ }
207
+ }
208
+ this.#keymap = out;
209
+ }
210
+ whichKey(node, opts = {}) {
211
+ const ret = [];
212
+ const actions = Object.keys(node.actions ?? {}).map((id) => this.get(id)).filter((a) => filterAction(a, opts.filter)).toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
213
+ const used = /* @__PURE__ */ new Set();
214
+ for (const action of actions) {
215
+ const keys = (action.keys ?? []).map((k) => canonical(k)).filter((k) => !used.has(k));
216
+ if (keys.length === 0) continue;
217
+ keys.forEach((k) => used.add(k));
218
+ ret.push({
219
+ ...action,
220
+ keys
221
+ });
222
+ }
223
+ return ret;
224
+ }
225
+ };
226
+ //#endregion
227
+ //#region src/input/decoder.ts
228
+ var Decoder = class {
229
+ #pending = "";
230
+ #paste;
231
+ feed(chunk) {
232
+ const out = [];
233
+ let buf = this.#pending + chunk;
234
+ this.#pending = "";
235
+ while (buf.length > 0) {
236
+ if (this.#paste !== void 0) {
237
+ const end = buf.indexOf(PASTE_END);
238
+ if (end === -1) {
239
+ this.#paste += buf;
240
+ buf = "";
241
+ continue;
242
+ }
243
+ this.#paste += buf.slice(0, end);
244
+ out.push({
245
+ text: this.#paste,
246
+ type: "paste"
247
+ });
248
+ this.#paste = void 0;
249
+ buf = buf.slice(end + PASTE_END.length);
250
+ continue;
251
+ }
252
+ if (buf[0] === "\x1B") {
253
+ const consumed = parseEsc(buf, out, () => {
254
+ this.#paste = "";
255
+ });
256
+ if (consumed === 0) {
257
+ this.#pending = buf;
258
+ return out;
259
+ }
260
+ buf = buf.slice(consumed);
261
+ continue;
262
+ }
263
+ const step = decodeChar(buf, out, { alt: false });
264
+ buf = buf.slice(step);
265
+ }
266
+ return out;
267
+ }
268
+ /**
269
+ * Force-emit any state we've been holding. Currently only the bare-ESC
270
+ * case: if `feed` ended on a lone `\x1b` we hold it in case `a` follows
271
+ * (alt+a); call `flush` after a short idle timeout to commit it as an
272
+ * `esc` key press.
273
+ */
274
+ flush() {
275
+ if (this.#pending === "\x1B") {
276
+ this.#pending = "";
277
+ return [key({ name: "esc" })];
278
+ }
279
+ return [];
280
+ }
281
+ };
282
+ const PASTE_END = "\x1B[201~";
283
+ function parseEsc(buf, out, onPasteStart) {
284
+ if (buf.length === 1) return 0;
285
+ const second = buf[1];
286
+ if (second === "[") return parseCsi(buf, out, onPasteStart);
287
+ if (second === "O") return parseSs3(buf, out);
288
+ if (second === "]") return parseStringControl(buf, out, "osc");
289
+ if (second === "P") return parseStringControl(buf, out, "dcs");
290
+ if (second === "_") return parseStringControl(buf, out, "apc");
291
+ return 1 + decodeChar(buf.slice(1), out, { alt: true });
292
+ }
293
+ function parseCsi(buf, out, onPasteStart) {
294
+ let i = 2;
295
+ while (i < buf.length) {
296
+ const code = buf.charCodeAt(i);
297
+ if (code >= 64 && code <= 126) {
298
+ const params = buf.slice(2, i);
299
+ const final = buf[i];
300
+ const consumed = i + 1;
301
+ if (params === "200" && final === "~") {
302
+ onPasteStart();
303
+ return consumed;
304
+ }
305
+ if (params === "" && final === "I") {
306
+ out.push({
307
+ gained: true,
308
+ type: "focus"
309
+ });
310
+ return consumed;
311
+ }
312
+ if (params === "" && final === "O") {
313
+ out.push({
314
+ gained: false,
315
+ type: "focus"
316
+ });
317
+ return consumed;
318
+ }
319
+ if (isTerminalCsiResponse(params, final)) {
320
+ out.push({
321
+ final,
322
+ kind: "csi",
323
+ params,
324
+ sequence: buf.slice(0, consumed),
325
+ type: "term-response"
326
+ });
327
+ return consumed;
328
+ }
329
+ handleCsi(params, final, out);
330
+ return consumed;
331
+ }
332
+ i++;
333
+ }
334
+ return 0;
335
+ }
336
+ function parseStringControl(buf, out, kind) {
337
+ const payloadStart = 2;
338
+ const st = buf.indexOf("\x1B\\", payloadStart);
339
+ const bel = kind === "osc" ? buf.indexOf("\x07", payloadStart) : -1;
340
+ const useBel = bel !== -1 && (st === -1 || bel < st);
341
+ const end = useBel ? bel : st;
342
+ if (end === -1) return 0;
343
+ const consumed = end + (useBel ? 1 : 2);
344
+ out.push({
345
+ kind,
346
+ payload: buf.slice(payloadStart, end),
347
+ sequence: buf.slice(0, consumed),
348
+ type: "term-response"
349
+ });
350
+ return consumed;
351
+ }
352
+ function parseSs3(buf, out) {
353
+ if (buf.length < 3) return 0;
354
+ const final = buf[2];
355
+ const name = SS3_NAMES[final];
356
+ if (name !== void 0) out.push(key({ name }));
357
+ return 3;
358
+ }
359
+ const ARROW_NAMES = {
360
+ A: "up",
361
+ B: "down",
362
+ C: "right",
363
+ D: "left",
364
+ F: "end",
365
+ H: "home"
366
+ };
367
+ const TILDE_NAMES = {
368
+ "1": "home",
369
+ "11": "f1",
370
+ "12": "f2",
371
+ "13": "f3",
372
+ "14": "f4",
373
+ "15": "f5",
374
+ "17": "f6",
375
+ "18": "f7",
376
+ "19": "f8",
377
+ "2": "insert",
378
+ "20": "f9",
379
+ "21": "f10",
380
+ "23": "f11",
381
+ "24": "f12",
382
+ "3": "delete",
383
+ "4": "end",
384
+ "5": "pageup",
385
+ "6": "pagedown",
386
+ "7": "home",
387
+ "8": "end"
388
+ };
389
+ const SS3_NAMES = {
390
+ P: "f1",
391
+ Q: "f2",
392
+ R: "f3",
393
+ S: "f4"
394
+ };
395
+ function isTerminalCsiResponse(params, final) {
396
+ return final === "c" && (params.startsWith("?") || params.startsWith(">"));
397
+ }
398
+ function handleCsi(params, final, out) {
399
+ if ((final === "M" || final === "m") && params.startsWith("<")) {
400
+ handleMouse(params, final, out);
401
+ return;
402
+ }
403
+ const parts = params.length === 0 ? [] : params.split(";");
404
+ const arrow = ARROW_NAMES[final];
405
+ if (arrow !== void 0) {
406
+ const mod = parts.length === 2 ? Number.parseInt(parts[1], 10) : 1;
407
+ out.push(key({
408
+ name: arrow,
409
+ ...modBits(mod)
410
+ }));
411
+ return;
412
+ }
413
+ if (final === "~") {
414
+ const code = parts[0] ?? "";
415
+ const name = TILDE_NAMES[code];
416
+ if (name === void 0) return;
417
+ const mod = parts.length === 2 ? Number.parseInt(parts[1], 10) : 1;
418
+ out.push(key({
419
+ name,
420
+ ...modBits(mod)
421
+ }));
422
+ return;
423
+ }
424
+ }
425
+ function handleMouse(params, final, out) {
426
+ const [button, x, y] = params.slice(1).split(";").map((p) => Number.parseInt(p, 10));
427
+ if (!Number.isFinite(button) || !Number.isFinite(x) || !Number.isFinite(y)) return;
428
+ const code = button & 3;
429
+ const scroll = (button & 64) !== 0;
430
+ const mods = mouseModBits(button);
431
+ if (scroll) {
432
+ out.push({
433
+ ...mods,
434
+ deltaY: code === 0 ? -1 : 1,
435
+ kind: "scroll",
436
+ type: "mouse",
437
+ x,
438
+ y
439
+ });
440
+ return;
441
+ }
442
+ const mouseButton = buttonName(code);
443
+ if (!mouseButton) return;
444
+ const kind = mouseKind(button, final);
445
+ out.push({
446
+ ...mods,
447
+ button: mouseButton,
448
+ kind,
449
+ type: "mouse",
450
+ x,
451
+ y
452
+ });
453
+ }
454
+ function mouseKind(button, final) {
455
+ if (final === "m") return "up";
456
+ if ((button & 32) !== 0) return "drag";
457
+ return "down";
458
+ }
459
+ function buttonName(code) {
460
+ if (code === 0) return "left";
461
+ if (code === 1) return "middle";
462
+ if (code === 2) return "right";
463
+ }
464
+ function mouseModBits(button) {
465
+ return {
466
+ alt: (button & 8) !== 0,
467
+ ctrl: (button & 16) !== 0,
468
+ meta: false,
469
+ shift: (button & 4) !== 0
470
+ };
471
+ }
472
+ function modBits(m) {
473
+ const v = Number.isFinite(m) ? m - 1 : 0;
474
+ return {
475
+ alt: (v & 2) !== 0,
476
+ ctrl: (v & 4) !== 0,
477
+ meta: (v & 8) !== 0,
478
+ shift: (v & 1) !== 0
479
+ };
480
+ }
481
+ function decodeChar(buf, out, opts) {
482
+ const alt = opts.alt;
483
+ const code = buf.charCodeAt(0);
484
+ if (code === 9) {
485
+ out.push(key({
486
+ alt,
487
+ name: "tab"
488
+ }));
489
+ return 1;
490
+ }
491
+ if (code === 10 || code === 13) {
492
+ out.push(key({
493
+ alt,
494
+ name: "enter"
495
+ }));
496
+ return 1;
497
+ }
498
+ if (code === 8 || code === 127) {
499
+ out.push(key({
500
+ alt,
501
+ name: "backspace"
502
+ }));
503
+ return 1;
504
+ }
505
+ if (code === 32) {
506
+ out.push(key({
507
+ alt,
508
+ name: "space",
509
+ text: " "
510
+ }));
511
+ return 1;
512
+ }
513
+ if (code >= 1 && code <= 26) {
514
+ const letter = String.fromCharCode(code + 96);
515
+ out.push(key({
516
+ alt,
517
+ ctrl: true,
518
+ name: letter
519
+ }));
520
+ return 1;
521
+ }
522
+ const cp = buf.codePointAt(0) ?? 0;
523
+ const ch = String.fromCodePoint(cp);
524
+ const isUpper = ch.length === 1 && ch >= "A" && ch <= "Z";
525
+ out.push(key({
526
+ alt,
527
+ name: ch,
528
+ shift: isUpper,
529
+ text: ch
530
+ }));
531
+ return ch.length;
532
+ }
533
+ function key(partial) {
534
+ return {
535
+ event: {
536
+ alt: partial.alt ?? false,
537
+ ctrl: partial.ctrl ?? false,
538
+ meta: partial.meta ?? false,
539
+ name: partial.name,
540
+ shift: partial.shift ?? false,
541
+ ...partial.text !== void 0 ? { text: partial.text } : {}
542
+ },
543
+ type: "key"
544
+ };
545
+ }
546
+ //#endregion
547
+ //#region src/input/defaults.ts
548
+ /**
549
+ * Catalog of built-in actions with descriptions and default bindings.
550
+ * Typed as `Record<BuiltinAction, ActionInfo>` so missing entries are a
551
+ * compile error — add an action to a widget and you'll be nudged to
552
+ * document it here.
553
+ *
554
+ * The Renderer registers this into its `actions` registry at
555
+ * construction; apps compose further catalogs via
556
+ * `renderer.actions.register(...)`.
557
+ */
558
+ const defaultActions = {
559
+ "global.quit": {
560
+ cmd: "quit",
561
+ desc: "quit",
562
+ keys: ["ctrl-c"]
563
+ },
564
+ "input.cursorDown": {
565
+ desc: "move cursor down one line",
566
+ hidden: true,
567
+ keys: ["down"]
568
+ },
569
+ "input.cursorLeft": {
570
+ desc: "move cursor left",
571
+ hidden: true,
572
+ keys: ["left"]
573
+ },
574
+ "input.cursorLineEnd": {
575
+ desc: "jump to end of current line",
576
+ hidden: true,
577
+ keys: ["end", "ctrl-e"]
578
+ },
579
+ "input.cursorLineStart": {
580
+ desc: "jump to start of current line",
581
+ hidden: true,
582
+ keys: ["home", "ctrl-a"]
583
+ },
584
+ "input.cursorRight": {
585
+ desc: "move cursor right",
586
+ hidden: true,
587
+ keys: ["right"]
588
+ },
589
+ "input.cursorUp": {
590
+ desc: "move cursor up one line",
591
+ hidden: true,
592
+ keys: ["up"]
593
+ },
594
+ "input.deleteCharBack": {
595
+ desc: "delete the character before the cursor",
596
+ hidden: true,
597
+ keys: ["backspace"]
598
+ },
599
+ "input.deleteCharForward": {
600
+ desc: "delete the character at the cursor",
601
+ hidden: true,
602
+ keys: ["delete"]
603
+ },
604
+ "input.deleteWordBack": {
605
+ desc: "delete the word before the cursor",
606
+ hidden: true,
607
+ keys: ["ctrl-w"]
608
+ },
609
+ "input.insertNewline": {
610
+ desc: "insert a newline at the cursor (copies leading indent)",
611
+ hidden: true,
612
+ keys: ["shift-enter", "alt-enter"]
613
+ },
614
+ "input.insertTab": {
615
+ desc: "insert an indent (two spaces) at the cursor",
616
+ hidden: true,
617
+ keys: ["tab"]
618
+ },
619
+ "input.paste": {
620
+ desc: "paste text from the system clipboard, or attach a pasted image",
621
+ hidden: true,
622
+ keys: ["ctrl-v"]
623
+ },
624
+ "input.submit": {
625
+ desc: "submit the current value",
626
+ hidden: true,
627
+ keys: ["enter"]
628
+ },
629
+ "picker.next": {
630
+ desc: "move to the next item that matches the query",
631
+ hidden: true,
632
+ keys: ["ctrl-n"]
633
+ },
634
+ "picker.prev": {
635
+ desc: "move to the previous item that matches the query",
636
+ hidden: true,
637
+ keys: ["ctrl-p"]
638
+ },
639
+ "select.accept": {
640
+ desc: "select the active item",
641
+ hidden: true,
642
+ keys: ["enter"]
643
+ },
644
+ "select.close": {
645
+ desc: "close",
646
+ keys: ["esc"]
647
+ },
648
+ "select.complete": {
649
+ desc: "complete the active item",
650
+ hidden: true,
651
+ keys: ["tab"]
652
+ },
653
+ "select.first": {
654
+ desc: "jump to the first item",
655
+ hidden: true,
656
+ keys: ["home"]
657
+ },
658
+ "select.last": {
659
+ desc: "jump to the last item",
660
+ hidden: true,
661
+ keys: ["end"]
662
+ },
663
+ "select.next": {
664
+ desc: "move to the next item",
665
+ hidden: true,
666
+ keys: ["down"]
667
+ },
668
+ "select.page-down": {
669
+ desc: "move down one page",
670
+ hidden: true,
671
+ keys: ["pagedown", "ctrl-d"]
672
+ },
673
+ "select.page-up": {
674
+ desc: "move up one page",
675
+ hidden: true,
676
+ keys: ["pageup", "ctrl-u"]
677
+ },
678
+ "select.prev": {
679
+ desc: "move to the previous item",
680
+ hidden: true,
681
+ keys: ["up"]
682
+ }
683
+ };
684
+ //#endregion
685
+ //#region src/input/router.ts
686
+ /**
687
+ * Routes decoder events to the right node and/or action.
688
+ *
689
+ * Dispatch priority for a key event (most-specific-first):
690
+ *
691
+ * 1. **Per-node `"key"` bubble** — focused node up to root. Each
692
+ * node's `on("key")` / `bind()` handlers fire. Calling `.stop()`
693
+ * ends dispatch. This is where local overrides live.
694
+ * 2. **Keymap lookup** — a single table built by the `Actions`
695
+ * registry (`canonical pattern → action id | direct handler`).
696
+ * Action ids are dispatched through `Actions.dispatch(id)`,
697
+ * which walks the focus chain for `node.actions[id]` — or
698
+ * calls a catalog-level `fn` if the action has one.
699
+ * 3. **Global `bind()` handlers** — a last-resort fallback for
700
+ * ad-hoc app-level bindings registered via `router.bind(...)`.
701
+ *
702
+ * Paste events follow a simple emit-and-bubble path — no keymap.
703
+ */
704
+ var InputRouter = class extends Emitter {
705
+ #focused = [];
706
+ /** Set by the Renderer so keymap-matched action ids can be
707
+ * dispatched through the catalog. */
708
+ #actions;
709
+ #logger;
710
+ #mouseClick;
711
+ #terminalFocus = true;
712
+ constructor(logger) {
713
+ super();
714
+ this.#logger = logger ?? new Logger();
715
+ }
716
+ /** Internal — wired by the Renderer at construction. */
717
+ setActions(actions) {
718
+ this.#actions = actions;
719
+ }
720
+ /** Currently-focused node, or `undefined` when none. */
721
+ get focused() {
722
+ this.#focused = this.#focused.filter((n) => n.mounted);
723
+ return this.#focused.at(-1);
724
+ }
725
+ /**
726
+ * Move focus to `node`. Emits `blur`
727
+ * on the previously-focused node and `focus` on the new one.
728
+ * Focusing the already-focused node is a no-op.
729
+ */
730
+ focus(node) {
731
+ if (!node.mounted || this.focused === node) return;
732
+ const prev = this.focused;
733
+ this.#focused = this.#focused.filter((n) => n !== node);
734
+ this.#focused.push(node);
735
+ if (prev !== void 0) {
736
+ prev.emit("blur");
737
+ this.emit("blur", { node: prev });
738
+ }
739
+ node.emit("focus");
740
+ this.emit("focus", { node });
741
+ }
742
+ /** Clear focus from `node`. Emits `blur` on the node and `focus`
743
+ * on the next-most-recently-focused node, if any. */
744
+ blur(node) {
745
+ const top = this.focused;
746
+ this.#focused = this.#focused.filter((n) => n !== node);
747
+ if (top !== node) return;
748
+ const next = this.focused;
749
+ node.emit("blur");
750
+ this.emit("blur", { node });
751
+ if (next !== void 0) {
752
+ next.emit("focus");
753
+ this.emit("focus", { node: next });
754
+ }
755
+ }
756
+ get terminalFocus() {
757
+ return this.#terminalFocus;
758
+ }
759
+ /**
760
+ * Dispatch a decoder event. Returns `true` if consumed.
761
+ */
762
+ dispatch(ev) {
763
+ return this.#logger.try(() => this.#dispatch(ev), "dispatch") ?? false;
764
+ }
765
+ #dispatch(ev) {
766
+ if (ev.type === "key") return this.#dispatchKey(ev.event);
767
+ if (ev.type === "paste") return this.#dispatchPaste(ev.text);
768
+ if (ev.type === "term-response") {
769
+ this.emit("term-response", { event: ev });
770
+ return false;
771
+ }
772
+ if (ev.type === "mouse") {
773
+ this.emit("mouse", { event: this.#routeMouse(ev) });
774
+ return false;
775
+ } else {
776
+ this.#terminalFocus = ev.gained;
777
+ this.emit("terminal-focus", { gained: ev.gained });
778
+ }
779
+ return false;
780
+ }
781
+ #routeMouse(event) {
782
+ if (event.kind !== "down") return event;
783
+ const now = Date.now();
784
+ const prev = this.#mouseClick;
785
+ const count = prev !== void 0 && prev.button === event.button && prev.x === event.x && prev.y === event.y && now - prev.time <= 500 ? prev.count % 3 + 1 : 1;
786
+ this.#mouseClick = {
787
+ button: event.button,
788
+ count,
789
+ time: now,
790
+ x: event.x,
791
+ y: event.y
792
+ };
793
+ return {
794
+ ...event,
795
+ click: count
796
+ };
797
+ }
798
+ #dispatchKey(event) {
799
+ this.emit("key", { event });
800
+ const routed = makeRoutedKey(event);
801
+ if (this.#actions?.dispatchKey(routed, {
802
+ global: false,
803
+ node: true
804
+ })) return true;
805
+ for (let node = this.focused; node !== void 0; node = node.parent) {
806
+ node.emit("key", { key: routed });
807
+ if (routed.stopped) return true;
808
+ }
809
+ return this.#actions?.dispatchKey(routed, {
810
+ global: true,
811
+ node: false
812
+ }) ?? false;
813
+ }
814
+ #dispatchPaste(text) {
815
+ const routed = makeRoutedPaste(text);
816
+ let node = this.focused;
817
+ while (node !== void 0) {
818
+ node.emit("paste", { paste: routed });
819
+ if (routed.stopped) return true;
820
+ node = node.parent;
821
+ }
822
+ return false;
823
+ }
824
+ };
825
+ function makeRoutedKey(ev) {
826
+ const r = {
827
+ alt: ev.alt,
828
+ ctrl: ev.ctrl,
829
+ meta: ev.meta,
830
+ name: ev.name,
831
+ pattern: canonical(ev),
832
+ shift: ev.shift,
833
+ stop: () => {
834
+ r.stopped = true;
835
+ },
836
+ stopped: false
837
+ };
838
+ if (ev.text !== void 0) r.text = ev.text;
839
+ return r;
840
+ }
841
+ function makeRoutedPaste(text) {
842
+ const r = {
843
+ stop: () => {
844
+ r.stopped = true;
845
+ },
846
+ stopped: false,
847
+ text
848
+ };
849
+ return r;
850
+ }
851
+ //#endregion
852
+ //#region src/renderer/frame.ts
853
+ function sliceLine(line, from, to, width) {
854
+ const start = from ? Math.max(0, from - 1) : 0;
855
+ const end = to ? Math.max(start, to - 1) : width;
856
+ if (end <= start) return;
857
+ const lineWidth = stringWidth(line);
858
+ if (start >= lineWidth) return;
859
+ const clampedEnd = Math.min(end, lineWidth);
860
+ return {
861
+ from: start + 1,
862
+ line,
863
+ to: clampedEnd + 1
864
+ };
865
+ }
866
+ var Frame = class {
867
+ terminal;
868
+ #base = [];
869
+ #current = [];
870
+ constructor(terminal) {
871
+ this.terminal = terminal;
872
+ }
873
+ reset(rows = this.terminal.rows) {
874
+ this.#base = Array(rows).fill(void 0);
875
+ this.#current = Array(rows).fill(void 0);
876
+ }
877
+ begin() {
878
+ const rows = this.terminal.rows;
879
+ if (this.#current.length !== rows || this.#base.length !== rows) this.reset(rows);
880
+ return new RenderFrame(this.terminal, this.#current, this.#base);
881
+ }
882
+ slice(row, from, to, width = this.terminal.cols) {
883
+ return sliceLine(this.#current[row - 1] ?? "", from, to, width);
884
+ }
885
+ };
886
+ var RenderFrame = class {
887
+ terminal;
888
+ current;
889
+ base;
890
+ #ops = [];
891
+ #next;
892
+ constructor(terminal, current, base) {
893
+ this.terminal = terminal;
894
+ this.current = current;
895
+ this.base = base;
896
+ this.#next = base.map((row) => row ?? "");
897
+ }
898
+ get(row) {
899
+ return this.#next[row - 1] ?? "";
900
+ }
901
+ set(row, content) {
902
+ if (row < 1 || row > this.#next.length) return;
903
+ this.#next[row - 1] = content;
904
+ }
905
+ clear(row) {
906
+ this.set(row, "");
907
+ }
908
+ overlay(row, col, content) {
909
+ if (row < 1 || row > this.#next.length) return;
910
+ const base = this.get(row);
911
+ const start = Math.max(0, col - 1);
912
+ const width = stringWidth(content);
913
+ const prefix = sliceAnsi(base, 0, start);
914
+ const pad = " ".repeat(Math.max(0, start - stringWidth(prefix)));
915
+ this.set(row, `${prefix}${pad}${content}${sliceAnsi(base, start + width)}`);
916
+ }
917
+ slice(row, from, to, width = this.terminal.cols) {
918
+ if (row < 1 || row > this.#next.length) return;
919
+ return sliceLine(this.get(row), from, to, width);
920
+ }
921
+ highlight(row, from, to, ctx) {
922
+ const ret = this.slice(row, from, to, ctx.width);
923
+ if (!ret) return;
924
+ const start = ret.from - 1;
925
+ const end = (ret.to ?? ctx.width + 1) - 1;
926
+ const text = sliceAnsi(ret.line, start, end);
927
+ const highlighted = ctx.style.selection(stripAnsiBg(text));
928
+ this.set(row, `${sliceAnsi(ret.line, 0, start)}${highlighted}${sliceAnsi(ret.line, end)}`);
929
+ return ret;
930
+ }
931
+ queue(op) {
932
+ this.#ops.push(op);
933
+ }
934
+ commitBase() {
935
+ for (let i = 0; i < this.#next.length; i++) this.base[i] = this.#next[i];
936
+ }
937
+ flush() {
938
+ for (let i = 0; i < this.#next.length; i++) {
939
+ const next = this.#next[i];
940
+ if (this.current[i] === next) continue;
941
+ const row = i + 1;
942
+ this.queue((terminal) => {
943
+ terminal.write(terminal.moveTo(row, 1) + terminal.clearLine() + next.trimEnd());
944
+ });
945
+ this.current[i] = next;
946
+ }
947
+ }
948
+ scrollUp(top, bottom, lines, op) {
949
+ if (lines <= 0 || top > bottom) return;
950
+ this.flush();
951
+ this.queue(op);
952
+ const from = Math.max(1, top);
953
+ const to = Math.min(this.#next.length, bottom);
954
+ const amount = Math.min(lines, to - from + 1);
955
+ for (let i = from - 1; i < to; i++) {
956
+ const source = i + amount;
957
+ const value = source < to ? this.current[source] ?? "" : "";
958
+ this.current[i] = value;
959
+ this.#next[i] = value;
960
+ }
961
+ }
962
+ scrollDown(top, bottom, lines, op) {
963
+ if (lines <= 0 || top > bottom) return;
964
+ this.flush();
965
+ this.queue(op);
966
+ const from = Math.max(1, top);
967
+ const to = Math.min(this.#next.length, bottom);
968
+ const amount = Math.min(lines, to - from + 1);
969
+ for (let i = to - 1; i >= from - 1; i--) {
970
+ const source = i - amount;
971
+ const value = source >= from - 1 ? this.current[source] ?? "" : "";
972
+ this.current[i] = value;
973
+ this.#next[i] = value;
974
+ }
975
+ }
976
+ paint() {
977
+ const ops = this.#ops.length;
978
+ this.flush();
979
+ const rows = this.#ops.length - ops;
980
+ for (const op of this.#ops) op(this.terminal);
981
+ this.#ops.length = 0;
982
+ return {
983
+ ops,
984
+ rows
985
+ };
986
+ }
987
+ };
988
+ //#endregion
989
+ //#region src/renderer/surface.ts
990
+ /**
991
+ * Shared base for the three renderer surfaces (`Stream`, `UI`,
992
+ * `OverlaySurface`). Owns the common lifecycle and paint bookkeeping:
993
+ *
994
+ * - `#running` flag and `#mountCtx` reference, driven by the Renderer
995
+ * `start` / `stop` events. Subclasses read `mountCtx` to mount nodes
996
+ * appended while the renderer is running.
997
+ * - `#dirty`, set by `invalidate()`, means this surface has content or
998
+ * state changes and schedules a frame.
999
+ * Concrete surfaces override `mountAll` / `unmountAll` to walk whichever
1000
+ * node set they track, and `_render()` to compute rows and enqueue a paint
1001
+ * closure into the renderer's atomic terminal sync.
1002
+ *
1003
+ * @internal
1004
+ */
1005
+ var Surface = class extends Emitter {
1006
+ #running = false;
1007
+ #mountCtx;
1008
+ #rendering;
1009
+ $r;
1010
+ #dirty = false;
1011
+ constructor(renderer) {
1012
+ super();
1013
+ this.$r = renderer.on("start", () => {
1014
+ if (this.#running) return;
1015
+ this.#running = true;
1016
+ this.#mountCtx = this.$r.mountCtx(this.type);
1017
+ this.mountAll(this.#mountCtx);
1018
+ }).on("stop", () => {
1019
+ if (!this.#running) return;
1020
+ this.#running = false;
1021
+ this.unmountAll();
1022
+ this.#mountCtx = void 0;
1023
+ });
1024
+ }
1025
+ /** Check if this surface contains the given screen position */
1026
+ contains(point) {
1027
+ const { top, bottom } = this.bounds;
1028
+ return point.row >= top && point.row <= bottom;
1029
+ }
1030
+ get dirty() {
1031
+ return this.#dirty;
1032
+ }
1033
+ track(ev, inc = 1) {
1034
+ this.$r.stats.inc(ev, inc);
1035
+ }
1036
+ /** Mark this surface as changed and schedule a renderer frame. */
1037
+ invalidate = () => {
1038
+ this.#dirty = true;
1039
+ this.track(`${this.type}.dirty`);
1040
+ untrack(() => void this.$r.emit("dirty"));
1041
+ };
1042
+ /** `true` between `onStart()` and `onStop()`. Subclasses read this
1043
+ * to decide whether an append should mount immediately or wait. */
1044
+ get running() {
1045
+ return this.#running;
1046
+ }
1047
+ /** The MountCtx handed in by the Renderer on `start()`. Undefined
1048
+ * when the surface isn't running. Subclasses read this to mount
1049
+ * nodes appended while the renderer is up. */
1050
+ get mountCtx() {
1051
+ return this.#mountCtx;
1052
+ }
1053
+ async render(frame) {
1054
+ const ownFrame = frame ?? this.$r.frame.begin();
1055
+ return this.#rendering ??= (async () => {
1056
+ this.#dirty = false;
1057
+ await this._render(ownFrame);
1058
+ if (frame === void 0) this.$r.terminal.sync(() => ownFrame.paint());
1059
+ })().finally(() => {
1060
+ this.#rendering = void 0;
1061
+ });
1062
+ }
1063
+ };
1064
+ //#endregion
1065
+ //#region src/renderer/overlay.ts
1066
+ /**
1067
+ * Overlay surface — paints absolute-positioned `Overlay` nodes on top
1068
+ * of the stream and UI surfaces after each tick.
1069
+ *
1070
+ * Open/close is explicit. While an overlay is open, mutations on its
1071
+ * state (or its subtree) bubble through `invalidate` → this surface's
1072
+ * `dirty` → Renderer's scheduler, same as the other surfaces. On
1073
+ * close, the covered area is overdrawn by asking the stream and UI
1074
+ * to invalidate so the next flush repaints them in full.
1075
+ *
1076
+ * v1 is intentionally minimal:
1077
+ * - Overlays are painted top-to-bottom in z-order on every flush; we
1078
+ * don't track per-overlay dirty flags, just paint all active ones.
1079
+ * - No clipping against the viewport — callers are responsible for
1080
+ * sizing / positioning sensibly. The stream lives inside DECSTBM,
1081
+ * so overlays that touch row 0 can disrupt scrollback.
1082
+ */
1083
+ var OverlaySurface = class extends Surface {
1084
+ type = "overlay";
1085
+ #overlays = [];
1086
+ /** Cached render state for each active overlay, in z-order (low -> high). */
1087
+ #state = [];
1088
+ get state() {
1089
+ return [...this.#state];
1090
+ }
1091
+ get bounds() {
1092
+ return {
1093
+ bottom: this.$r.terminal.rows,
1094
+ top: 1
1095
+ };
1096
+ }
1097
+ get dirty() {
1098
+ return this.active.length > 0 || super.dirty;
1099
+ }
1100
+ /** Active overlays in paint order (low → high z-index). */
1101
+ get active() {
1102
+ return this.#overlays.filter((o) => o.visible && o.ctx !== void 0);
1103
+ }
1104
+ /** Return the topmost overlay at the given `(row, col)`, if any. */
1105
+ at({ row, col }) {
1106
+ return this.#state.findLast((s) => {
1107
+ if (row < s.y || row >= s.y + s.height) return false;
1108
+ if (col < s.x || col >= s.x + s.width) return false;
1109
+ return true;
1110
+ });
1111
+ }
1112
+ contains(point) {
1113
+ return this.at(point) !== void 0;
1114
+ }
1115
+ /** Register an overlay with the surface. Function form runs `fn`
1116
+ * inside a fresh Owner scope and adopts the returned Overlay. */
1117
+ add(overlay) {
1118
+ const resolved = withOwner(this.$r.rootOwner, () => createNode(overlay));
1119
+ if (this.#overlays.includes(resolved)) return resolved;
1120
+ this.#overlays.push(resolved);
1121
+ this.#overlays.sort((a, b) => (a.state.zIndex ?? 0) - (b.state.zIndex ?? 0));
1122
+ resolved.on("invalidate", this.invalidate);
1123
+ resolved.on("unmount", () => this.remove(resolved));
1124
+ const ctx = this.mountCtx;
1125
+ if (this.running && ctx) resolved.mount(ctx);
1126
+ if (resolved.visible) this.invalidate();
1127
+ return resolved;
1128
+ }
1129
+ remove(overlay) {
1130
+ const i = this.#overlays.indexOf(overlay);
1131
+ if (i === -1) return this;
1132
+ this.#overlays.splice(i, 1);
1133
+ overlay.emit("close");
1134
+ if (overlay.mounted) overlay.unmount();
1135
+ overlay.off("invalidate", this.invalidate);
1136
+ this.invalidate();
1137
+ return this;
1138
+ }
1139
+ open(overlay) {
1140
+ return this.add(overlay).show();
1141
+ }
1142
+ close(overlay) {
1143
+ overlay.hide();
1144
+ this.remove(overlay);
1145
+ return this;
1146
+ }
1147
+ /** Every currently-open overlay node, for renderer traversals. */
1148
+ get nodes() {
1149
+ return this.#overlays;
1150
+ }
1151
+ mountAll(ctx) {
1152
+ for (const o of this.#overlays) if (!o.mounted) o.mount(ctx);
1153
+ }
1154
+ unmountAll() {
1155
+ for (const o of this.#overlays) if (o.mounted) o.unmount();
1156
+ }
1157
+ /**
1158
+ * Render pass: compute rows for each active overlay, then paint them
1159
+ * at their absolute `(y, x)` inside the outer sync block. Matches the
1160
+ * `render(sync?)` shape used by Stream and UI so the Renderer can
1161
+ * capture all three paints and emit them in one atomic frame.
1162
+ */
1163
+ async _render(frame) {
1164
+ const painted = [];
1165
+ const ctx = this.$r.ctx;
1166
+ this.#state = await Promise.all(this.active.map(async (node) => {
1167
+ const rows = await node.render(ctx);
1168
+ const height = rows.length;
1169
+ const width = Math.max(...rows.map((r) => stringWidth(r)));
1170
+ let x = node.state.x;
1171
+ let y = node.state.y;
1172
+ if (x < 1 && x > 0) x = Math.floor(this.$r.terminal.cols * x);
1173
+ if (x < 0) x = this.$r.terminal.cols + x;
1174
+ let refY = 1;
1175
+ let refHeight = this.$r.terminal.rows;
1176
+ if (node.state.relative === "ui") {
1177
+ refHeight = this.$r.ui.height;
1178
+ refY = this.$r.terminal.footerTop;
1179
+ } else if (node.state.relative === "stream") refHeight = this.$r.stream.liveHeight;
1180
+ if (y < 1 && y > 0) y = Math.floor(refHeight * y);
1181
+ const anchorY = y < 0 ? refY + refHeight + y : refY + y - 1;
1182
+ const va = node.state.verticalAnchor ?? "top";
1183
+ if (va === "bottom") y = anchorY - height + 1;
1184
+ else if (va === "center") y = anchorY - Math.floor(height / 2);
1185
+ else y = anchorY;
1186
+ const ha = node.state.horizontalAnchor ?? "left";
1187
+ if (ha === "right") x = x - width + 1;
1188
+ else if (ha === "center") x -= Math.floor(width / 2);
1189
+ painted.push({
1190
+ rows,
1191
+ x,
1192
+ y
1193
+ });
1194
+ const s = {
1195
+ height,
1196
+ node,
1197
+ width,
1198
+ x,
1199
+ y,
1200
+ zIndex: node.state.zIndex ?? 0
1201
+ };
1202
+ this.emit("render-node", s);
1203
+ return s;
1204
+ }));
1205
+ for (const { rows, x, y } of painted) for (let r = 0; r < rows.length; r++) frame.overlay(y + r, x, rows[r]);
1206
+ }
1207
+ };
1208
+ //#endregion
1209
+ //#region src/renderer/selection.ts
1210
+ var SelectionLayer = class extends Emitter {
1211
+ #selection;
1212
+ #text = "";
1213
+ #dirty = false;
1214
+ #emitted;
1215
+ $r;
1216
+ constructor(renderer) {
1217
+ super();
1218
+ this.$r = renderer;
1219
+ }
1220
+ get selection() {
1221
+ return this.#selection;
1222
+ }
1223
+ set selection(value) {
1224
+ const prev = this.#selection;
1225
+ if (isDeepStrictEqual(prev, value)) return;
1226
+ this.#selection = value;
1227
+ this.#dirty = true;
1228
+ this.#emitted = void 0;
1229
+ if (!value) {
1230
+ this.#text = "";
1231
+ this.#dirty = false;
1232
+ }
1233
+ this.invalidate();
1234
+ this.emit("change", {
1235
+ prev,
1236
+ selection: value
1237
+ });
1238
+ }
1239
+ get text() {
1240
+ return this.#selection ? this.#text : void 0;
1241
+ }
1242
+ set text(value) {
1243
+ if (this.#text === value && !this.#dirty) return;
1244
+ this.#text = value;
1245
+ const selection = this.#selection;
1246
+ if (!selection || selection.dragging || this.#emitted === selection) return;
1247
+ this.#dirty = false;
1248
+ setImmediate(() => {
1249
+ if (selection !== this.#selection || value !== this.#text) return;
1250
+ this.#emitted = selection;
1251
+ this.emit("selection", {
1252
+ selection,
1253
+ text: value
1254
+ });
1255
+ });
1256
+ }
1257
+ invalidate() {
1258
+ this.$r.overlay.invalidate();
1259
+ untrack(() => void this.$r.emit("dirty"));
1260
+ }
1261
+ mouse(event) {
1262
+ if (event.kind === "scroll") return false;
1263
+ if (event.button !== "left") return false;
1264
+ const point = this.#hit(event);
1265
+ if (event.kind === "down") {
1266
+ if (event.click === 2) {
1267
+ this.select(point.screen, "word");
1268
+ return true;
1269
+ }
1270
+ if (event.click === 3) {
1271
+ this.select(point.screen, "line");
1272
+ return true;
1273
+ }
1274
+ this.selection = point.stream ? {
1275
+ dragging: true,
1276
+ from: point.stream,
1277
+ surface: "stream",
1278
+ to: point.stream
1279
+ } : {
1280
+ dragging: true,
1281
+ from: point.screen,
1282
+ surface: "screen",
1283
+ to: point.screen
1284
+ };
1285
+ return true;
1286
+ }
1287
+ const selection = this.#selection;
1288
+ if (!selection?.dragging) return false;
1289
+ if (event.kind === "drag") {
1290
+ this.selection = this.#update(selection, point, true);
1291
+ return true;
1292
+ }
1293
+ const next = this.#update(selection, point, false);
1294
+ this.selection = samePoint(next.from, next.to) ? void 0 : next;
1295
+ return true;
1296
+ }
1297
+ clear() {
1298
+ if (!this.#selection) return;
1299
+ this.selection = void 0;
1300
+ }
1301
+ select(screen, what) {
1302
+ const point = this.#hitPoint(screen);
1303
+ const line = this.$r.frame.slice(screen.row, 1, void 0);
1304
+ if (!line) return;
1305
+ const slice = what === "line" ? lineRange(line.line) : wordRange(line.line, screen.col);
1306
+ if (!slice) return;
1307
+ const row = point.stream?.row ?? screen.row;
1308
+ this.selection = {
1309
+ dragging: false,
1310
+ from: {
1311
+ col: slice.from,
1312
+ row
1313
+ },
1314
+ surface: point.stream ? "stream" : "screen",
1315
+ to: {
1316
+ col: slice.to,
1317
+ row
1318
+ }
1319
+ };
1320
+ }
1321
+ renderStream(frame, ctx) {
1322
+ if (this.#selection?.surface === "stream") this.render(frame, ctx);
1323
+ }
1324
+ renderScreen(frame, ctx) {
1325
+ if (this.#selection?.surface === "screen") this.render(frame, ctx);
1326
+ }
1327
+ render(frame, ctx) {
1328
+ const selection = this.#selection;
1329
+ if (!selection) return;
1330
+ const from = this.#toScreen(selection.surface, selection.from);
1331
+ const to = this.#toScreen(selection.surface, selection.to);
1332
+ const start = before(from, to) ? from : to;
1333
+ const end = start === from ? to : from;
1334
+ const bounds = selection.surface === "stream" ? this.$r.stream.bounds : void 0;
1335
+ const highlighted = renderRange({
1336
+ bounds,
1337
+ ctx,
1338
+ end,
1339
+ frame,
1340
+ start
1341
+ });
1342
+ this.text = normalizeText(selection.surface === "stream" ? this.#streamSlices(start, end, bounds) : highlighted);
1343
+ }
1344
+ #hit(event) {
1345
+ return this.#hitPoint({
1346
+ col: event.x,
1347
+ row: event.y
1348
+ });
1349
+ }
1350
+ #hitPoint(screen) {
1351
+ if (this.$r.overlay.contains(screen)) return { screen };
1352
+ if (this.$r.ui.contains(screen)) return { screen };
1353
+ return {
1354
+ screen,
1355
+ stream: this.$r.stream.contains(screen) ? this.$r.stream.fromScreen(screen) : void 0
1356
+ };
1357
+ }
1358
+ #update(selection, point, dragging) {
1359
+ if (selection.surface === "stream" && point.stream) return {
1360
+ ...selection,
1361
+ dragging,
1362
+ to: point.stream
1363
+ };
1364
+ return {
1365
+ dragging,
1366
+ from: selection.surface === "stream" ? this.$r.stream.toScreen(selection.from) : selection.from,
1367
+ surface: "screen",
1368
+ to: point.screen
1369
+ };
1370
+ }
1371
+ #toScreen(surface, point) {
1372
+ return surface === "stream" ? this.$r.stream.toScreen(point) : point;
1373
+ }
1374
+ #streamSlices(start, end, bounds) {
1375
+ const ret = [];
1376
+ const firstRow = Math.max(start.row, bounds.top);
1377
+ const lastRow = Math.min(end.row, bounds.bottom);
1378
+ if (lastRow < firstRow) return ret;
1379
+ for (let row = firstRow; row <= lastRow; row++) {
1380
+ const streamPoint = this.$r.stream.fromScreen({
1381
+ col: 1,
1382
+ row
1383
+ });
1384
+ if (!streamPoint) continue;
1385
+ const line = this.$r.stream.getRow(streamPoint.row);
1386
+ if (line === void 0) continue;
1387
+ ret.push({
1388
+ from: row === start.row ? start.col : 1,
1389
+ line,
1390
+ to: row === end.row ? end.col : void 0
1391
+ });
1392
+ }
1393
+ return ret;
1394
+ }
1395
+ };
1396
+ const WORD = /^[\w\-/\\@]$/;
1397
+ function lineRange(line) {
1398
+ const text = stripAnsi(line);
1399
+ const start = text.search(/\S/);
1400
+ if (start === -1) return;
1401
+ let end = text.length;
1402
+ while (end > start && /\s/.test(text[end - 1])) end--;
1403
+ return {
1404
+ from: start + 1,
1405
+ to: end + 1
1406
+ };
1407
+ }
1408
+ function wordRange(line, col) {
1409
+ const text = stripAnsi(line);
1410
+ const index = col - 1;
1411
+ if (index < 0 || index >= text.length) return;
1412
+ const match = charClass(text[index]);
1413
+ let start = index;
1414
+ while (start > 0 && charClass(text[start - 1]) === match) start--;
1415
+ let end = index + 1;
1416
+ while (end < text.length && charClass(text[end]) === match) end++;
1417
+ return {
1418
+ from: start + 1,
1419
+ to: end + 1
1420
+ };
1421
+ }
1422
+ function charClass(char) {
1423
+ if (/\s/.test(char)) return "space";
1424
+ if (WORD.test(char)) return "word";
1425
+ return "punct";
1426
+ }
1427
+ function samePoint(a, b) {
1428
+ return a.row === b.row && a.col === b.col;
1429
+ }
1430
+ function before(a, b) {
1431
+ return a.row < b.row || a.row === b.row && a.col <= b.col;
1432
+ }
1433
+ function normalizeText(slices) {
1434
+ const lines = slices.map((slice) => {
1435
+ const full = stripAnsi(slice.line).trimEnd();
1436
+ const text = stripAnsi(sliceAnsi(slice.line, slice.from - 1, slice.to ? slice.to - 1 : void 0)).trimEnd();
1437
+ return {
1438
+ from: slice.from,
1439
+ full,
1440
+ text
1441
+ };
1442
+ });
1443
+ const indent = commonIndent(lines.map((line) => line.full));
1444
+ return lines.map((line) => stripIndent(line.text, line.from, indent)).join("\n");
1445
+ }
1446
+ function commonIndent(lines) {
1447
+ const nonEmpty = lines.filter((line) => line.trim() !== "");
1448
+ if (nonEmpty.length === 0) return 0;
1449
+ return Math.min(...nonEmpty.map((line) => line.match(/^[ \t]*/)?.[0].length ?? 0));
1450
+ }
1451
+ function stripIndent(line, from, indent) {
1452
+ if (indent === 0 || line.trim() === "") return line;
1453
+ const visibleBeforeSelection = from - 1;
1454
+ const remove = Math.max(0, indent - visibleBeforeSelection);
1455
+ return remove === 0 ? line : line.slice(remove);
1456
+ }
1457
+ function renderRange({ bounds, ctx, end, frame, start }) {
1458
+ const lines = [];
1459
+ const firstRow = bounds ? Math.max(start.row, bounds.top) : start.row;
1460
+ const lastRow = bounds ? Math.min(end.row, bounds.bottom) : end.row;
1461
+ if (lastRow < firstRow) return lines;
1462
+ if (firstRow === lastRow) {
1463
+ const from = firstRow === start.row ? start.col : 1;
1464
+ const to = firstRow === end.row ? end.col : void 0;
1465
+ const text = frame.highlight(firstRow, from, to, ctx);
1466
+ if (text !== void 0) lines.push(text);
1467
+ return lines;
1468
+ }
1469
+ const first = frame.highlight(firstRow, firstRow === start.row ? start.col : 1, void 0, ctx);
1470
+ if (first !== void 0) lines.push(first);
1471
+ for (let row = firstRow + 1; row < lastRow; row++) {
1472
+ const text = frame.highlight(row, 1, void 0, ctx);
1473
+ if (text !== void 0) lines.push(text);
1474
+ }
1475
+ const last = frame.highlight(lastRow, 1, lastRow === end.row ? end.col : void 0, ctx);
1476
+ if (last !== void 0) lines.push(last);
1477
+ return lines;
1478
+ }
1479
+ //#endregion
1480
+ //#region src/renderer/stream.ts
1481
+ /**
1482
+ * Stream surface — append-only list of nodes that render into the bottom
1483
+ * of the terminal's scroll region and commit to scrollback as newer
1484
+ * content arrives.
1485
+ *
1486
+ * We keep previously-appended nodes in `#state` as long as any of their
1487
+ * rows are still on-screen, so `rows` (the getter) can report the full
1488
+ * visible slice, and so the overlay surface can reconstruct what was
1489
+ * underneath it. Only the current tail is `live` — committed nodes are
1490
+ * frozen.
1491
+ *
1492
+ * Scrollback commits happen via `\n` at `scrollBottom`: the only
1493
+ * guaranteed-portable way to move a row into scrollback (xterm.js and
1494
+ * ghostty-web's WASM parser both ignore `CSI S` for scrollback
1495
+ * promotion). So growth of the stream always flows through the bottom
1496
+ * row with a `\n`-prefix write per new row.
1497
+ */
1498
+ function findFrozenPrefixEnd(rendered, frozen) {
1499
+ if (frozen.length === 0) return 0;
1500
+ for (let start = 0; start <= rendered.length - frozen.length; start++) {
1501
+ let match = true;
1502
+ for (let i = 0; i < frozen.length; i++) if (rendered[start + i] !== frozen[i]) {
1503
+ match = false;
1504
+ break;
1505
+ }
1506
+ if (match) return start + frozen.length;
1507
+ }
1508
+ return frozen.length;
1509
+ }
1510
+ const SCROLL_DURATION = 120;
1511
+ var Stream = class extends Surface {
1512
+ type = "stream";
1513
+ #state = [];
1514
+ #snapshot = {
1515
+ bottom: 0,
1516
+ historyLength: 0,
1517
+ rows: [],
1518
+ scrollTop: 0,
1519
+ top: 1,
1520
+ virtual: false,
1521
+ visible: []
1522
+ };
1523
+ #opts;
1524
+ #scrollback = [];
1525
+ /** 0 = follow live bottom; otherwise 1-based top row inside history. */
1526
+ #scrollTop = 0;
1527
+ #scrollAnim;
1528
+ constructor(renderer, opts = {}) {
1529
+ super(renderer);
1530
+ this.#opts = {
1531
+ fixedFooterHeight: opts.fixedFooterHeight ?? 0,
1532
+ maxLive: opts.maxLive ?? 3
1533
+ };
1534
+ this.emitScroll = throttle(this.emitScroll.bind(this), 1e3 / 60);
1535
+ }
1536
+ get bounds() {
1537
+ return {
1538
+ bottom: this.terminal.scrollBottom,
1539
+ top: 1
1540
+ };
1541
+ }
1542
+ get terminal() {
1543
+ return this.$r.terminal;
1544
+ }
1545
+ /**
1546
+ * Append `node` as the new live tail. The previous tail is frozen —
1547
+ * it stops receiving re-renders even if its state mutates.
1548
+ *
1549
+ * Function form: `append(() => node)` runs the function inside a
1550
+ * fresh Owner scope (so `signal` / `effect` / `onCleanup` /
1551
+ * `provideContext` inside `fn` attach to that scope) and appends the
1552
+ * returned Node. The Owner disposes when the Node unmounts.
1553
+ */
1554
+ append(node) {
1555
+ const boundary = createSuspenseBoundary();
1556
+ const resolved = withOwner(this.$r.rootOwner, () => createNode(() => {
1557
+ provideContext(SuspenseContext, boundary);
1558
+ return node();
1559
+ }));
1560
+ const invalidate = () => {
1561
+ state.dirty = true;
1562
+ this.invalidate();
1563
+ };
1564
+ const state = {
1565
+ boundary,
1566
+ commit: 0,
1567
+ dirty: true,
1568
+ freeze: () => {
1569
+ if (state.node.mounted) state.node.unmount();
1570
+ state.node.off("invalidate", invalidate);
1571
+ },
1572
+ get live() {
1573
+ return this.boundary.active() || this.dirty || !!this.node.state.sticky;
1574
+ },
1575
+ node: resolved
1576
+ };
1577
+ this.#state.push(state);
1578
+ const ctx = this.mountCtx;
1579
+ if (this.running && ctx) resolved.mount(ctx);
1580
+ resolved.on("invalidate", invalidate);
1581
+ this.invalidate();
1582
+ return resolved;
1583
+ }
1584
+ /** How many rows the live region may occupy. */
1585
+ get liveHeight() {
1586
+ return this.terminal.scrollBottom;
1587
+ }
1588
+ /** Tracked nodes (oldest first). Used by renderer traversals. */
1589
+ get nodes() {
1590
+ return this.#state.map((s) => s.node);
1591
+ }
1592
+ async waitIdle() {
1593
+ if (!this.pending) return;
1594
+ await new Promise((resolve) => this.once("idle", resolve));
1595
+ }
1596
+ /**
1597
+ * Rows currently painted in the live region, top-to-bottom. Bottom
1598
+ * of the returned array aligns with `terminal.scrollBottom`. Exposed
1599
+ * so the overlay surface can restore what it drew over.
1600
+ */
1601
+ get rows() {
1602
+ return this.#snapshot.visible;
1603
+ }
1604
+ getRow(row) {
1605
+ return this.#snapshot.rows[row - 1];
1606
+ }
1607
+ fromScreen(point) {
1608
+ const { top, visible, scrollTop } = this.#snapshot;
1609
+ if (point.row < top || point.row >= top + visible.length) return;
1610
+ return {
1611
+ col: point.col,
1612
+ row: scrollTop + point.row - top
1613
+ };
1614
+ }
1615
+ toScreen(point) {
1616
+ const { top, scrollTop } = this.#snapshot;
1617
+ const offset = point.row - scrollTop;
1618
+ return {
1619
+ col: point.col,
1620
+ row: top + offset
1621
+ };
1622
+ }
1623
+ get #historyLength() {
1624
+ return this.#snapshot.historyLength;
1625
+ }
1626
+ emitScroll() {
1627
+ setImmediate(() => {
1628
+ this.emit("scroll", {
1629
+ below: this.#scrollTop === 0 ? 0 : this.#historyLength - this.#scrollTop - this.liveHeight + 1,
1630
+ offset: this.#scrollTop === 0 ? this.#historyLength : this.#scrollTop,
1631
+ total: this.#historyLength
1632
+ });
1633
+ });
1634
+ }
1635
+ scroll(lines = .5) {
1636
+ const amount = Math.trunc(Math.abs(lines) < 1 ? lines * this.liveHeight : lines);
1637
+ if (amount === 0) return Promise.resolve();
1638
+ const total = this.#historyLength;
1639
+ const maxTop = Math.max(1, total - this.liveHeight + 1);
1640
+ const current = this.#scrollTop === 0 ? maxTop : this.#scrollTop;
1641
+ const target = Math.max(1, Math.min(maxTop, current + amount));
1642
+ if (target === current) return Promise.resolve();
1643
+ this.#scrollAnim?.cancel();
1644
+ const ret = Promise.withResolvers();
1645
+ const delta = Math.abs(target - current);
1646
+ const dir = Math.sign(target - current);
1647
+ const stepTime = Math.max(8, Math.min(16, SCROLL_DURATION / delta));
1648
+ const stepAmount = Math.max(1, Math.ceil(delta / Math.ceil(SCROLL_DURATION / stepTime)));
1649
+ const tick = () => {
1650
+ const lastTop = Math.max(1, this.#historyLength - this.liveHeight + 1);
1651
+ const top = this.#scrollTop === 0 ? lastTop : this.#scrollTop;
1652
+ const remaining = Math.abs(target - top);
1653
+ if (remaining === 0) {
1654
+ this.#scrollTop = target >= lastTop ? 0 : target;
1655
+ this.emitScroll();
1656
+ ret.resolve();
1657
+ this.#scrollAnim = void 0;
1658
+ return;
1659
+ }
1660
+ const next = top + dir * Math.min(stepAmount, remaining);
1661
+ this.#scrollTop = next >= lastTop ? 0 : next;
1662
+ this.emitScroll();
1663
+ this.invalidate();
1664
+ const t = setTimeout(tick, stepTime);
1665
+ t.unref();
1666
+ this.#scrollAnim = { cancel: () => {
1667
+ clearTimeout(t);
1668
+ ret.resolve();
1669
+ this.#scrollAnim = void 0;
1670
+ } };
1671
+ };
1672
+ tick();
1673
+ return ret.promise;
1674
+ }
1675
+ async scrollBottom() {
1676
+ if (this.#scrollTop === 0) return;
1677
+ await this.scroll(Math.max(1, this.#historyLength - this.liveHeight + 1));
1678
+ if (this.#scrollTop === 0) return;
1679
+ if (this.#scrollAnim) return;
1680
+ this.#scrollTop = 0;
1681
+ this.emitScroll();
1682
+ this.invalidate();
1683
+ }
1684
+ scrollTop() {
1685
+ return this.scroll(-this.#historyLength + 1);
1686
+ }
1687
+ scrollUp(lines = .5) {
1688
+ return this.scroll(-lines);
1689
+ }
1690
+ scrollDown(lines = .5) {
1691
+ return this.scroll(lines);
1692
+ }
1693
+ async #render() {
1694
+ const ctx = this.$r.ctx;
1695
+ await Promise.all(this.#state.map(async (s) => {
1696
+ const prev = s.rows ?? [];
1697
+ const len = prev.length;
1698
+ const frozen = prev.slice(0, s.commit);
1699
+ s.dirty = false;
1700
+ const rendered = await s.node.render(ctx);
1701
+ const mutableStart = s.commit === 0 ? 0 : findFrozenPrefixEnd(rendered, frozen);
1702
+ s.rows = [...frozen, ...rendered.slice(mutableStart)];
1703
+ if (s.rows.length < len) s.rows.push(...Array(len - s.rows.length).fill(""));
1704
+ }));
1705
+ await Promise.resolve();
1706
+ const states = [];
1707
+ const sticky = [];
1708
+ for (const s of this.#state) if (s.node.state.sticky) sticky.push(s);
1709
+ else states.push(s);
1710
+ states.push(...sticky);
1711
+ this.#state = [...states];
1712
+ const rows = [];
1713
+ let commitLimit;
1714
+ for (const s of this.#state) {
1715
+ if (commitLimit === void 0 && s.live) commitLimit = rows.length;
1716
+ if (s.rows) rows.push(...s.rows);
1717
+ }
1718
+ return {
1719
+ commitLimit: commitLimit ?? rows.length,
1720
+ rows
1721
+ };
1722
+ }
1723
+ async _render(frame) {
1724
+ const { commitLimit, rows: allRows } = await this.#render();
1725
+ const old = this.#snapshot;
1726
+ const liveHeight = this.liveHeight;
1727
+ const committed = this.#committedRows();
1728
+ const commitThreshold = Math.max(1, this.terminal.rows - this.#opts.fixedFooterHeight);
1729
+ const liveStart = Math.min(commitLimit, Math.max(committed, allRows.length - commitThreshold));
1730
+ const commit = allRows.slice(committed, liveStart);
1731
+ this.#scrollback.push(...commit);
1732
+ const liveRows = allRows.slice(liveStart);
1733
+ const historyLength = this.#scrollback.length + liveRows.length;
1734
+ const maxTop = Math.max(1, historyLength - liveHeight + 1);
1735
+ if (this.#scrollTop > maxTop) this.#scrollTop = 0;
1736
+ let newVisible = liveRows.slice(-liveHeight);
1737
+ const historyChanged = this.#historyLength !== historyLength;
1738
+ let deleteImages;
1739
+ if (this.#scrollTop > 0) {
1740
+ if (historyChanged) this.emitScroll();
1741
+ const start = this.#scrollTop - 1;
1742
+ const sbl = this.#scrollback.length;
1743
+ if (start >= sbl) newVisible = liveRows.slice(start - sbl, start - sbl + liveHeight);
1744
+ else {
1745
+ newVisible = this.#scrollback.slice(start, start + liveHeight);
1746
+ if (newVisible.length < liveHeight) newVisible.push(...liveRows.slice(0, liveHeight - newVisible.length));
1747
+ }
1748
+ }
1749
+ if (this.#scrollTop > 0 || this.terminal.altScreen) {
1750
+ const kittyPlacements = bumpPlacements(newVisible);
1751
+ if (kittyPlacements) {
1752
+ newVisible = kittyPlacements.rows;
1753
+ deleteImages = kittyPlacements.delete;
1754
+ }
1755
+ }
1756
+ const next = {
1757
+ bottom: liveHeight,
1758
+ deleteVirtualPlacements: deleteImages,
1759
+ historyLength,
1760
+ rows: [...this.#scrollback, ...liveRows],
1761
+ scrollTop: this.#scrollTop === 0 ? maxTop : this.#scrollTop,
1762
+ top: liveHeight - newVisible.length + 1,
1763
+ virtual: this.#scrollTop > 0,
1764
+ visible: newVisible
1765
+ };
1766
+ const plan = {
1767
+ commit,
1768
+ frame,
1769
+ next,
1770
+ old
1771
+ };
1772
+ this.#paint(plan);
1773
+ this.#snapshot = next;
1774
+ this.#advanceCommits(commit.length);
1775
+ this.#dropCommittedStates();
1776
+ if (!this.pending) this.emit("idle");
1777
+ }
1778
+ #paint(plan) {
1779
+ if (plan.old.deleteVirtualPlacements) plan.frame.queue((terminal) => terminal.write(plan.old.deleteVirtualPlacements()));
1780
+ if (!this.terminal.altScreen) this.#commitToScrollback(plan);
1781
+ this.#clearAboveVisible(plan);
1782
+ this.#paintVirtualScroll(plan);
1783
+ this.#paintVisible(plan);
1784
+ }
1785
+ #commitToScrollback(plan) {
1786
+ let i = 0;
1787
+ while (i < plan.commit.length) {
1788
+ const batch = Math.min(plan.commit.length - i, plan.next.bottom);
1789
+ for (let j = 0; j < batch; j++) {
1790
+ const row = 1 + j;
1791
+ const content = plan.commit[i + j];
1792
+ plan.frame.set(row, content);
1793
+ }
1794
+ plan.frame.scrollUp(1, plan.next.bottom, batch, (terminal) => {
1795
+ terminal.write(terminal.moveTo(plan.next.bottom, 1));
1796
+ for (let j = 0; j < batch; j++) terminal.write("\n");
1797
+ });
1798
+ i += batch;
1799
+ }
1800
+ }
1801
+ #clearAboveVisible(plan) {
1802
+ const clearTop = plan.old.bottom !== plan.next.bottom ? Math.max(1, Math.min(plan.old.top, plan.next.top)) : 1;
1803
+ for (let r = clearTop; r < plan.next.top; r++) {
1804
+ if (r < 1 || r > plan.next.bottom) continue;
1805
+ plan.frame.clear(r);
1806
+ }
1807
+ }
1808
+ #paintVirtualScroll(plan) {
1809
+ if (plan.commit.length > 0) return;
1810
+ if (!plan.old.virtual || !plan.next.virtual) return;
1811
+ if (plan.old.bottom !== plan.next.bottom) return;
1812
+ if (plan.old.visible.length !== plan.next.visible.length) return;
1813
+ if (plan.next.visible.length === 0) return;
1814
+ const delta = plan.next.scrollTop - plan.old.scrollTop;
1815
+ const lines = Math.abs(delta);
1816
+ if (lines === 0 || lines >= plan.next.visible.length) return;
1817
+ if (delta > 0) plan.frame.scrollUp(1, plan.next.bottom, lines, (terminal) => {
1818
+ terminal.write(terminal.scrollUp(lines));
1819
+ });
1820
+ else plan.frame.scrollDown(1, plan.next.bottom, lines, (terminal) => {
1821
+ terminal.write(terminal.scrollDown(lines));
1822
+ });
1823
+ }
1824
+ #paintVisible(plan) {
1825
+ for (let k = 0; k < plan.next.visible.length; k++) {
1826
+ const row = plan.next.top + k;
1827
+ plan.frame.set(row, plan.next.visible[k]);
1828
+ }
1829
+ }
1830
+ #advanceCommits(commitCount) {
1831
+ let remaining = commitCount;
1832
+ for (const s of this.#state) {
1833
+ if (remaining <= 0) break;
1834
+ const len = s.rows?.length ?? 0;
1835
+ const n = Math.min(remaining, Math.max(0, len - s.commit));
1836
+ s.commit += n;
1837
+ remaining -= n;
1838
+ }
1839
+ }
1840
+ #dropCommittedStates() {
1841
+ while (this.#state.length > 0) {
1842
+ const first = this.#state[0];
1843
+ const len = first.rows?.length ?? 0;
1844
+ if (first.live || first.commit < len) break;
1845
+ this.#state.shift();
1846
+ first.freeze();
1847
+ }
1848
+ }
1849
+ #committedRows() {
1850
+ return this.#state.reduce((sum, s) => sum + s.commit, 0);
1851
+ }
1852
+ get pending() {
1853
+ return this.active;
1854
+ }
1855
+ get active() {
1856
+ return this.#state.some((s) => s.boundary.active());
1857
+ }
1858
+ /**
1859
+ * Terminal was resized. Forget only the paint mirror that mapped stream
1860
+ * rows onto the old screen geometry. App scrollback and per-state commit
1861
+ * boundaries are preserved; retained nodes re-render at the new width
1862
+ * through Node.render()'s cache key.
1863
+ *
1864
+ * Paired with a screen-clear in the Renderer's resize handler — this
1865
+ * method only resets our mirror of what's on screen; the actual wipe
1866
+ * and re-establishment of DECSTBM lives in the terminal-level handler.
1867
+ */
1868
+ onResize() {
1869
+ this.resetPaint();
1870
+ }
1871
+ resetPaint() {
1872
+ this.#snapshot = {
1873
+ ...this.#snapshot,
1874
+ bottom: this.terminal.scrollBottom,
1875
+ rows: [],
1876
+ scrollTop: 0,
1877
+ top: 1,
1878
+ virtual: false,
1879
+ visible: []
1880
+ };
1881
+ this.#scrollAnim?.cancel();
1882
+ this.invalidate();
1883
+ }
1884
+ reset(opts = {}) {
1885
+ this.resetPaint();
1886
+ this.#scrollback = [];
1887
+ this.#snapshot.historyLength = 0;
1888
+ this.#scrollTop = 0;
1889
+ for (const s of this.#state) s.commit = 0;
1890
+ if (!opts.keepNodes) {
1891
+ for (const s of this.#state) s.freeze();
1892
+ this.#state = [];
1893
+ }
1894
+ }
1895
+ mountAll(ctx) {
1896
+ for (const s of this.#state) if (!s.node.mounted) s.node.mount(ctx);
1897
+ }
1898
+ unmountAll() {
1899
+ for (const s of this.#state) if (s.node.mounted) s.node.unmount();
1900
+ }
1901
+ };
1902
+ //#endregion
1903
+ //#region src/renderer/terminal.ts
1904
+ /**
1905
+ * Terminal primitive — owns stdin raw mode, stdout writes, and the
1906
+ * standard set of control modes we flip for a direct-mode renderer:
1907
+ * hide cursor, DECAWM off, DECSTBM scroll region, synchronized output.
1908
+ *
1909
+ * No rendering logic lives here; upstream surfaces (stream, ui) push
1910
+ * escape sequences through the helpers exposed below.
1911
+ */
1912
+ const progressStates = {
1913
+ inactive: 0,
1914
+ progress: 1,
1915
+ error: 2,
1916
+ loading: 3,
1917
+ paused: 4
1918
+ };
1919
+ const CSI = "\x1B[";
1920
+ const ST = "\x1B\\";
1921
+ /** @internal */
1922
+ var Terminal = class {
1923
+ #stdout;
1924
+ #stdin;
1925
+ #altScreen;
1926
+ #hookSignals;
1927
+ #started = false;
1928
+ #reserveBottom;
1929
+ #mouse = false;
1930
+ #progress = { type: "inactive" };
1931
+ #progressInterval;
1932
+ #prevRawMode;
1933
+ #resizeListeners = /* @__PURE__ */ new Set();
1934
+ #signalCleanup;
1935
+ #onResize = () => {
1936
+ for (const l of this.#resizeListeners) l(this.cols, this.rows);
1937
+ };
1938
+ constructor(opts = {}) {
1939
+ this.#stdout = opts.stdout ?? process.stdout;
1940
+ this.#stdin = opts.stdin ?? process.stdin;
1941
+ this.#reserveBottom = opts.reserveBottom ?? 0;
1942
+ this.#altScreen = opts.altScreen ?? false;
1943
+ this.#hookSignals = opts.hookSignals ?? true;
1944
+ this.#mouse = opts.mouse ?? false;
1945
+ }
1946
+ /** Current terminal columns (number of cells wide). */
1947
+ get cols() {
1948
+ return this.#stdout.columns || 80;
1949
+ }
1950
+ /** Current terminal rows (number of cells tall). */
1951
+ get rows() {
1952
+ return this.#stdout.rows || 24;
1953
+ }
1954
+ /** Rows reserved at the bottom for the footer. */
1955
+ get reserveBottom() {
1956
+ return this.#reserveBottom;
1957
+ }
1958
+ /** Bottom row of the scroll region (1-based, inclusive). */
1959
+ get scrollBottom() {
1960
+ return Math.max(1, this.rows - this.#reserveBottom);
1961
+ }
1962
+ /** Top row of the footer (1-based, inclusive). 0 when no footer. */
1963
+ get footerTop() {
1964
+ return this.#reserveBottom > 0 ? this.rows - this.#reserveBottom + 1 : 0;
1965
+ }
1966
+ get altScreen() {
1967
+ return this.#altScreen;
1968
+ }
1969
+ get mouse() {
1970
+ return this.#mouse;
1971
+ }
1972
+ set mouse(mouse) {
1973
+ if (this.#mouse === mouse) return;
1974
+ this.#mouse = mouse;
1975
+ if (!this.#started) return;
1976
+ this.write(mouse ? `${CSI}?1002h${CSI}?1006h${CSI}?1007h` : `${CSI}?1007l${CSI}?1006l${CSI}?1002l`);
1977
+ }
1978
+ /**
1979
+ * Install control-mode state: hide cursor, DECAWM off, DECSTBM scroll
1980
+ * region, stdin raw mode, SIGWINCH + SIGINT/SIGTERM hooks.
1981
+ */
1982
+ start() {
1983
+ if (this.#started) return;
1984
+ this.#started = true;
1985
+ this.#writeProgress("progress", 0);
1986
+ this.#writeProgress("inactive");
1987
+ if (this.#altScreen) this.write(`${CSI}?1049h`);
1988
+ this.write(`${CSI}?25l${CSI}?7l${CSI}?2004h${CSI}?1004h`);
1989
+ if (this.#mouse) this.write(`${CSI}?1002h${CSI}?1006h${CSI}?1007h`);
1990
+ this.#writeProgress();
1991
+ if (this.#reserveBottom > 0) this.setScrollRegion(1, this.scrollBottom);
1992
+ if (this.#stdin?.isTTY && typeof this.#stdin.setRawMode === "function") {
1993
+ this.#prevRawMode = Boolean(this.#stdin.isRaw);
1994
+ this.#stdin.setRawMode(true);
1995
+ }
1996
+ process.on("SIGWINCH", this.#onResize);
1997
+ if (this.#hookSignals) {
1998
+ const bye = () => {
1999
+ this.stop();
2000
+ process.exit(0);
2001
+ };
2002
+ const onCrash = (err) => {
2003
+ this.stop();
2004
+ const insp = process.versions.bun ? Bun.inspect : inspect;
2005
+ process.stdout.write("", () => {
2006
+ process.stdout.write(insp(err, { colors: true }));
2007
+ });
2008
+ };
2009
+ process.once("uncaughtException", onCrash);
2010
+ process.once("SIGINT", bye);
2011
+ process.once("SIGTERM", bye);
2012
+ const onExit = () => this.stop();
2013
+ process.once("exit", onExit);
2014
+ this.#signalCleanup = () => {
2015
+ process.off("SIGINT", bye);
2016
+ process.off("SIGTERM", bye);
2017
+ process.off("exit", onExit);
2018
+ process.off("uncaughtException", onCrash);
2019
+ };
2020
+ }
2021
+ }
2022
+ deleteImages(opts = {}) {
2023
+ if (!this.#started) return;
2024
+ this.write(`\x1b_Ga=d,d=${opts.data ?? true ? "A" : "a"},q=2\x1b\\`);
2025
+ }
2026
+ /** Restore terminal modes, detach listeners. Idempotent. */
2027
+ stop() {
2028
+ if (!this.#started) return;
2029
+ try {
2030
+ this.deleteImages();
2031
+ this.setProgress();
2032
+ if (this.#reserveBottom > 0) this.clearScrollRegion();
2033
+ this.write(`${CSI}?1007l${CSI}?1006l${CSI}?1002l${CSI}?1000l${CSI}?1004l${CSI}?2004l${CSI}?7h${CSI}?25h`);
2034
+ if (this.#altScreen) this.write(`${CSI}?1049l`);
2035
+ } finally {
2036
+ this.#started = false;
2037
+ }
2038
+ if (this.#stdin?.isTTY && typeof this.#stdin.setRawMode === "function") {
2039
+ this.#stdin.setRawMode(this.#prevRawMode ?? false);
2040
+ this.#stdin.pause?.();
2041
+ }
2042
+ process.off("SIGWINCH", this.#onResize);
2043
+ this.#signalCleanup?.();
2044
+ this.#signalCleanup = void 0;
2045
+ }
2046
+ setProgress(state, value) {
2047
+ state ??= "inactive";
2048
+ const resetError = () => {
2049
+ this.#writeProgress("progress", 50);
2050
+ this.#writeProgress("loading");
2051
+ };
2052
+ if (this.#progress.type === "error" && state !== "error") resetError();
2053
+ if (state === "progress") this.#progress = {
2054
+ type: "progress",
2055
+ value: value ?? 0
2056
+ };
2057
+ else if (state === "error" || state === "paused") this.#progress = {
2058
+ type: state,
2059
+ value: value ?? this.#progress.value
2060
+ };
2061
+ else this.#progress = { type: state };
2062
+ this.#writeProgress();
2063
+ if (state === "inactive") {
2064
+ if (this.#progressInterval) clearInterval(this.#progressInterval);
2065
+ this.#progressInterval = void 0;
2066
+ } else if (!this.#progressInterval) {
2067
+ this.#progressInterval = setInterval(() => this.#writeProgress(), 100);
2068
+ this.#progressInterval.unref();
2069
+ }
2070
+ }
2071
+ #writeProgress(state, value) {
2072
+ if (!this.#started) return;
2073
+ value ??= this.#progress.value === void 0 ? void 0 : Math.max(0, Math.min(100, Math.round(this.#progress.value)));
2074
+ const seq = `\x1b]9;4;${progressStates[state ?? this.#progress.type]}${typeof value === "number" ? `;${value}` : ""}${ST}`;
2075
+ this.write(seq);
2076
+ }
2077
+ /** Change the bottom reservation and re-apply the scroll region. */
2078
+ setReserveBottom(rows) {
2079
+ const next = Math.max(0, Math.min(rows, this.rows - 1));
2080
+ if (next === this.#reserveBottom) return;
2081
+ this.#reserveBottom = next;
2082
+ if (!this.#started) return;
2083
+ if (next > 0) this.setScrollRegion(1, this.scrollBottom);
2084
+ else this.clearScrollRegion();
2085
+ }
2086
+ /** Low-level write. Use escape helpers + this to emit sequences. */
2087
+ write(s) {
2088
+ if (!this.#started) return;
2089
+ this.#stdout.write(s);
2090
+ }
2091
+ #write(s) {
2092
+ this.#stdout.write(s);
2093
+ }
2094
+ #pendingTransmits = [];
2095
+ /** Queue a side-channel ANSI payload (KGP transmit, etc.) for the
2096
+ * next `flushTransmits()`. No-op for empty strings. */
2097
+ enqueueTransmit(seq) {
2098
+ if (seq) this.#pendingTransmits.push(seq);
2099
+ }
2100
+ /** Drain the transmit queue. Called by the renderer at the start of
2101
+ * each paint, before frame composition. Idempotent when empty. */
2102
+ flushTransmits() {
2103
+ if (this.#pendingTransmits.length === 0) return;
2104
+ this.#stdout.write(this.#pendingTransmits.join(""));
2105
+ this.#pendingTransmits.length = 0;
2106
+ }
2107
+ /** Absolute cursor move (1-based). */
2108
+ moveTo(row, col = 1) {
2109
+ return `${CSI}${row};${col}H`;
2110
+ }
2111
+ /** Erase the entire line the cursor is on. */
2112
+ clearLine() {
2113
+ return `${CSI}2K`;
2114
+ }
2115
+ /** Clear from cursor to end of screen. */
2116
+ clearBelow() {
2117
+ return `${CSI}0J`;
2118
+ }
2119
+ /** Scroll the scroll region up by `n` rows (SU). Top rows enter scrollback. */
2120
+ scrollUp(n) {
2121
+ return n > 0 ? `${CSI}${n}S` : "";
2122
+ }
2123
+ /**
2124
+ * Scroll the scroll region down by `n` rows (SD). Top `n` rows become
2125
+ * blank, existing content shifts down, bottom `n` rows fall off the
2126
+ * region (they don't move to scrollback — scrollback is a top-side
2127
+ * concept; SD's displaced rows are simply lost).
2128
+ */
2129
+ scrollDown(n) {
2130
+ return n > 0 ? `${CSI}${n}T` : "";
2131
+ }
2132
+ /** Delete `n` lines at the cursor, pulling content below upward (DL). */
2133
+ deleteLines(n) {
2134
+ return n > 0 ? `${CSI}${n}M` : "";
2135
+ }
2136
+ /** Set the scroll region (DECSTBM), 1-based inclusive. */
2137
+ setScrollRegion(top, bottom) {
2138
+ this.write(`${CSI}${top};${bottom}r`);
2139
+ }
2140
+ /** Reset to the full-viewport scroll region. */
2141
+ clearScrollRegion() {
2142
+ this.write(`${CSI}r`);
2143
+ }
2144
+ /**
2145
+ * Wrap a block of writes in begin/end-synchronized-update. Supporting
2146
+ * terminals buffer the enclosed bytes and paint atomically — eliminates
2147
+ * flicker on multi-row diff rewrites. Unsupported terminals ignore
2148
+ * both escapes, so there's no downside.
2149
+ */
2150
+ sync(fn) {
2151
+ if (!this.#started) return;
2152
+ this.write(`${CSI}?2026h`);
2153
+ try {
2154
+ fn();
2155
+ } finally {
2156
+ this.#write(`${CSI}?2026l`);
2157
+ }
2158
+ }
2159
+ onResize(fn) {
2160
+ this.#resizeListeners.add(fn);
2161
+ return () => this.#resizeListeners.delete(fn);
2162
+ }
2163
+ };
2164
+ //#endregion
2165
+ //#region src/renderer/ui.ts
2166
+ /**
2167
+ * UI surface — the sticky footer. Renders a single Box tree at the
2168
+ * bottom of the terminal, inside the rows reserved by `Terminal`'s
2169
+ * scroll region. Row-diff redraws: only changed rows are rewritten on
2170
+ * each flush, so an input widget re-rendering every keystroke doesn't
2171
+ * repaint the entire footer.
2172
+ *
2173
+ * Height is determined by the root's rendered row count, capped to a
2174
+ * sensible upper bound so a runaway render can't take over the screen.
2175
+ * When the height changes the terminal's reserved bottom gets updated,
2176
+ * which rewrites `DECSTBM`; the stream surface then has less/more room
2177
+ * and its next flush naturally fills the new geometry.
2178
+ */
2179
+ var UI = class extends Surface {
2180
+ type = "ui";
2181
+ #root = box({ flexDirection: "column" });
2182
+ #rows = [];
2183
+ constructor(renderer) {
2184
+ super(renderer);
2185
+ this.#root.on("invalidate", this.invalidate);
2186
+ }
2187
+ /** The footer's root Box. Add children via `ui.root.add(child)`. */
2188
+ get root() {
2189
+ return this.#root;
2190
+ }
2191
+ get terminal() {
2192
+ return this.$r.terminal;
2193
+ }
2194
+ /** Shortcut: add a child to the footer root. Same semantics as
2195
+ * `ui.root.add(child)`; returns `this` for chaining.
2196
+ *
2197
+ * Function form: `add(() => box(…))` runs the function inside a
2198
+ * fresh Owner scope (so `signal` / `effect` / `onCleanup` /
2199
+ * `provideContext` inside `fn` attach to that scope) and adds the
2200
+ * returned Node. The Owner disposes when the Node unmounts. */
2201
+ add(child) {
2202
+ const ret = withOwner(this.$r.rootOwner, () => createNode(child));
2203
+ this.#root.add(ret);
2204
+ return ret;
2205
+ }
2206
+ /** Current rendered height (rows). */
2207
+ get height() {
2208
+ return this.#rows.length;
2209
+ }
2210
+ /**
2211
+ * The rows currently painted into the footer region, in order from the
2212
+ * top of the reserved area downward. Exposed so the overlay surface
2213
+ * can re-emit this content after it tears down, without re-rendering
2214
+ * the tree.
2215
+ */
2216
+ get rows() {
2217
+ return this.#rows;
2218
+ }
2219
+ /**
2220
+ * Render the footer tree and paint the diff against the last paint.
2221
+ * Updates the terminal's reservation whenever the height changes so
2222
+ * the scroll region stays correctly sized.
2223
+ *
2224
+ * When called from `Renderer.render()`, a capture-style `sync` is
2225
+ * provided so all surfaces paint inside one atomic sync frame. Direct
2226
+ * callers (tests) omit it and get an immediate `terminal.sync`.
2227
+ */
2228
+ async _render(frame) {
2229
+ const ctx = this.$r.ctx;
2230
+ const rendered = await this.#root.render({
2231
+ ...ctx,
2232
+ width: this.terminal.cols
2233
+ });
2234
+ const visible = Math.max(0, this.terminal.rows - 1);
2235
+ const rows = rendered.length > visible ? rendered.slice(-visible) : rendered;
2236
+ const prevHeight = this.#rows.length;
2237
+ const nextHeight = rows.length;
2238
+ if (nextHeight !== this.terminal.reserveBottom) {
2239
+ frame.flush();
2240
+ frame.queue((terminal) => terminal.setReserveBottom(nextHeight));
2241
+ this.$r.stream.invalidate();
2242
+ }
2243
+ const top = this.terminal.rows - nextHeight + 1;
2244
+ const prevTop = this.terminal.rows - prevHeight + 1;
2245
+ for (let row = prevTop; row < top; row++) frame.clear(row);
2246
+ for (let i = 0; i < nextHeight; i++) frame.set(top + i, rows[i]);
2247
+ this.#rows = rows;
2248
+ }
2249
+ /**
2250
+ * Terminal was resized. Drop the painted-rows mirror so the next
2251
+ * render's grow/shrink math treats the footer as if it's just
2252
+ * appearing — correct after a screen clear, when the terminal no
2253
+ * longer holds any footer bytes. The scroll region gets re-established
2254
+ * through the normal `setReserveBottom` path inside `render()`.
2255
+ */
2256
+ onResize() {
2257
+ this.#rows = [];
2258
+ this.invalidate();
2259
+ }
2260
+ /** UI's tracked node set is just the footer root. */
2261
+ get nodes() {
2262
+ return [this.#root];
2263
+ }
2264
+ mountAll(ctx) {
2265
+ if (!this.#root.mounted) this.#root.mount(ctx);
2266
+ }
2267
+ unmountAll() {
2268
+ if (this.#root.mounted) this.#root.unmount();
2269
+ }
2270
+ get bounds() {
2271
+ const top = this.terminal.footerTop || this.terminal.rows + 1;
2272
+ return {
2273
+ bottom: this.terminal.rows,
2274
+ top
2275
+ };
2276
+ }
2277
+ };
2278
+ //#endregion
2279
+ //#region src/renderer/renderer.ts
2280
+ var RenderStats = class {
2281
+ #stats = {};
2282
+ /** Increment a stat by `delta` (default: `1`). */
2283
+ inc(stat, delta = 1) {
2284
+ this.#stats[stat] = (this.#stats[stat] ?? 0) + delta;
2285
+ }
2286
+ set(stat, value) {
2287
+ this.#stats[stat] = value;
2288
+ }
2289
+ get() {
2290
+ return { ...this.#stats };
2291
+ }
2292
+ };
2293
+ /**
2294
+ * Wires a `Terminal` primitive with the `Stream` and `UI` surfaces.
2295
+ * Doesn't render anything until you either append to `renderer.stream`
2296
+ * or add children to `renderer.ui.root`.
2297
+ *
2298
+ * ```ts
2299
+ * const renderer = new Renderer()
2300
+ * renderer.start()
2301
+ * renderer.stream.append(text("hello"))
2302
+ * renderer.ui.root.add(input({ placeholder: "> " }))
2303
+ * // ... later
2304
+ * renderer.stop()
2305
+ * ```
2306
+ */
2307
+ var Renderer = class extends Emitter {
2308
+ stream;
2309
+ ui;
2310
+ overlay;
2311
+ selection;
2312
+ terminal;
2313
+ input;
2314
+ frame;
2315
+ /** Always-on logger, auto-attached to `this.stream`. Calling
2316
+ * `renderer.log("msg")` logs at `"log"` level; level methods
2317
+ * (`renderer.log.error(...)` etc.) are also available. */
2318
+ logger;
2319
+ #decoder = new Decoder();
2320
+ #stdin;
2321
+ #theme = signal(void 0);
2322
+ /** Root Owner — every surface's function-shape `add` / `append` runs
2323
+ * its child under this Owner via `withOwner(#rootOwner, …)`, so
2324
+ * context values provided here (`RenderContext`, future global
2325
+ * scopes) are visible to every widget body in the tree. */
2326
+ #rootOwner;
2327
+ #running = false;
2328
+ #escTimer;
2329
+ #rendering;
2330
+ #dirty = false;
2331
+ #debug = false;
2332
+ /** Monotonic cache-key for `RenderCtx`. Bumped on any event that
2333
+ * invalidates every node cache in the tree (resize, theme swap). */
2334
+ #ctxVersion = 0;
2335
+ #ctx;
2336
+ queries;
2337
+ /** Runtime action registry (catalog + dispatcher). Populated with
2338
+ * `defaultActions` + `globalActions` at construction; apps extend
2339
+ * via `renderer.actions.register({ "app.foo": { ... } })`. */
2340
+ actions;
2341
+ stats = new RenderStats();
2342
+ /** Global action impls. Merged into the `actions` catalog on
2343
+ * construction so the ids declared here gain their keys and desc
2344
+ * from `defaultActions`. Using `satisfies` (rather than an explicit
2345
+ * type annotation) keeps the literal keys so `BuiltinAction` can
2346
+ * pull them out. */
2347
+ globalActions = { "global.quit": { fn: () => {
2348
+ this.stop();
2349
+ process.exit(0);
2350
+ } } };
2351
+ constructor(opts = {}) {
2352
+ super();
2353
+ this.terminal = new Terminal({
2354
+ altScreen: opts.altScreen,
2355
+ hookSignals: opts.hookSignals,
2356
+ mouse: opts.mouse,
2357
+ stdin: opts.stdin,
2358
+ stdout: opts.stdout
2359
+ });
2360
+ this.frame = new Frame(this.terminal);
2361
+ if (opts.theme) this.#theme.set(opts.theme);
2362
+ this.ui = new UI(this);
2363
+ this.stream = new Stream(this, { fixedFooterHeight: opts.fixedFooterHeight });
2364
+ this.overlay = new OverlaySurface(this);
2365
+ this.selection = new SelectionLayer(this);
2366
+ this.logger = opts.logger ?? new Logger({ name: "renderer" });
2367
+ this.input = new InputRouter(this.logger.child({ name: "input" }));
2368
+ this.queries = new TerminalQueries(this.input, this.terminal);
2369
+ this.actions = new Actions(this.logger.child({ name: "actions" }));
2370
+ this.#rootOwner = createRoot(() => {
2371
+ provideContext(RenderContext, {
2372
+ images: memo(() => unwrap(opts.images) ?? true),
2373
+ queries: this.queries,
2374
+ style: memo(() => styleBuilder(this.#theme.get())),
2375
+ theme: this.#theme.get
2376
+ });
2377
+ return useActiveOwner();
2378
+ });
2379
+ const reporter = new TuiReporter(opts.reporter);
2380
+ reporter.attach({ append: (node) => this.stream.append(node) });
2381
+ this.logger.attach("tui", reporter);
2382
+ this.actions.setTargetResolver(() => this.input.focused);
2383
+ this.input.setActions(this.actions);
2384
+ this.input.on("mouse", ({ event }) => {
2385
+ if (event.kind === "scroll") this.stream.scroll(event.deltaY);
2386
+ else this.selection.mouse(event);
2387
+ });
2388
+ this.input.on("key", ({ event }) => {
2389
+ if (event.name === "esc") this.selection.clear();
2390
+ });
2391
+ this.actions.register(defaultActions, { default: true });
2392
+ this.actions.register(this.globalActions, { default: true });
2393
+ this.on("dirty", () => untrack(() => this.#schedule()));
2394
+ this.terminal.onResize(() => {
2395
+ this.#ctxVersion++;
2396
+ this.#ctx = void 0;
2397
+ this.terminal.sync(() => {
2398
+ this.terminal.clearScrollRegion();
2399
+ this.terminal.write(`${this.terminal.moveTo(1, 1)}\x1b[2J`);
2400
+ if (this.terminal.reserveBottom > 0) this.terminal.setScrollRegion(1, this.terminal.scrollBottom);
2401
+ });
2402
+ this.frame.reset();
2403
+ this.stream.onResize();
2404
+ this.ui.onResize();
2405
+ });
2406
+ this.#stdin = opts.stdin ?? process.stdin;
2407
+ }
2408
+ /**
2409
+ * Shared `RenderCtx` for the current tick. Built on demand and
2410
+ * reused across every `Node.render(ctx)` call until the version
2411
+ * bumps (resize, theme swap). Nodes compare `ctx.version` against
2412
+ * their cached row's version — simple integer check, no hashing.
2413
+ */
2414
+ get ctx() {
2415
+ if (this.#ctx === void 0 || this.#ctx.version !== this.#ctxVersion || this.#ctx.width !== this.terminal.cols) this.#ctx = createCtx({
2416
+ theme: peek(this.#theme.get),
2417
+ transmit: (seq) => this.terminal.enqueueTransmit(seq),
2418
+ version: this.#ctxVersion,
2419
+ width: this.terminal.cols
2420
+ });
2421
+ return this.#ctx;
2422
+ }
2423
+ get rootOwner() {
2424
+ return this.#rootOwner;
2425
+ }
2426
+ get mouse() {
2427
+ return this.terminal.mouse;
2428
+ }
2429
+ set mouse(mouse) {
2430
+ this.terminal.mouse = mouse;
2431
+ }
2432
+ get debug() {
2433
+ return this.#debug;
2434
+ }
2435
+ set debug(debug) {
2436
+ if (this.#debug === debug) return;
2437
+ this.#debug = debug;
2438
+ this.#schedule();
2439
+ }
2440
+ /** Whether the renderer is currently running. Surfaces use this to
2441
+ * decide whether a fresh append/open should immediately mount the
2442
+ * node or just queue it for the next `start()`. */
2443
+ get running() {
2444
+ return this.#running;
2445
+ }
2446
+ /** Shortcut for `renderer.input.bind(pattern, handler)`. Returns an
2447
+ * unsubscribe function. Use for one-off app-level bindings where a
2448
+ * named action would be overkill. */
2449
+ bind = (binding) => this.actions.bind(binding);
2450
+ /** Swap the ambient theme. Widgets that read `theme()` / `style()`
2451
+ * from `useContext(RenderContext)` re-fire automatically; primitive
2452
+ * Nodes that compare cache against `ctx.version` need the version
2453
+ * bump that already happens elsewhere on theme-driven invalidation. */
2454
+ set theme(theme) {
2455
+ this.#theme.set(theme);
2456
+ this.#ctxVersion++;
2457
+ this.stream.invalidate();
2458
+ this.ui.invalidate();
2459
+ this.overlay.invalidate();
2460
+ this.#schedule();
2461
+ }
2462
+ /** Get the current theme. (tracked) */
2463
+ get theme() {
2464
+ return this.#theme.get() ?? defaultTheme;
2465
+ }
2466
+ start() {
2467
+ if (this.#running) return;
2468
+ this.#running = true;
2469
+ this.terminal.start();
2470
+ this.#stdin.setEncoding?.("utf8");
2471
+ this.#stdin.on("data", this.#onData);
2472
+ this.#stdin.resume?.();
2473
+ this.emit("start");
2474
+ if (this.#dirty) this.#schedule();
2475
+ process.on("unhandledRejection", (reason) => this.logger.child("unhandledRejection").error("Unhandled Rejection:", reason));
2476
+ }
2477
+ stop() {
2478
+ if (!this.#running) return;
2479
+ this.emit("stop");
2480
+ this.#running = false;
2481
+ this.#stdin.off("data", this.#onData);
2482
+ this.#stdin.pause?.();
2483
+ if (this.#escTimer !== void 0) {
2484
+ clearTimeout(this.#escTimer);
2485
+ this.#escTimer = void 0;
2486
+ }
2487
+ this.terminal.stop();
2488
+ }
2489
+ /** Look up a node by its `id`. Returns `undefined` if nothing matches. */
2490
+ getNode(id) {
2491
+ let found;
2492
+ this.walk((n) => {
2493
+ if (n.id() !== id) return void 0;
2494
+ found = n;
2495
+ return "stop";
2496
+ });
2497
+ return found;
2498
+ }
2499
+ /**
2500
+ * Find every node matching a predicate. When passed a string, it's
2501
+ * interpreted as `node.type === string` — the common "find all
2502
+ * Inputs" case. For anything richer, pass a function.
2503
+ */
2504
+ findNode(match) {
2505
+ const pred = typeof match === "string" ? (n) => n.type === match : match;
2506
+ const out = [];
2507
+ this.walk((n) => {
2508
+ if (pred(n)) out.push(n);
2509
+ });
2510
+ return out;
2511
+ }
2512
+ /**
2513
+ * Walk every node in the combined stream + UI tree, depth-first,
2514
+ * parents before children. Return `"stop"` from the visitor to halt.
2515
+ */
2516
+ walk(visit) {
2517
+ const stopped = { v: false };
2518
+ const visitNode = (n) => {
2519
+ if (stopped.v) return;
2520
+ if (visit(n) === "stop") {
2521
+ stopped.v = true;
2522
+ return;
2523
+ }
2524
+ for (const c of n.children) visitNode(c);
2525
+ };
2526
+ visitNode(this.ui.root);
2527
+ for (const n of this.stream.nodes) visitNode(n);
2528
+ for (const n of this.overlay.nodes) visitNode(n);
2529
+ }
2530
+ /** Build a MountCtx for a given surface. Each call creates a fresh
2531
+ * object but closes over the same underlying services, so nodes
2532
+ * across surfaces share a single overlay surface, router, etc. */
2533
+ mountCtx(surface) {
2534
+ const logger = this.logger.child({
2535
+ name: surface,
2536
+ surface
2537
+ });
2538
+ const input = this.input;
2539
+ return {
2540
+ actions: this.actions,
2541
+ findNode: (m) => this.findNode(m),
2542
+ getNode: (id) => this.getNode(id),
2543
+ input: {
2544
+ bind: (binding) => this.actions.bind(binding),
2545
+ blur: (node) => this.input.blur(node),
2546
+ events: this.input,
2547
+ focus: (node) => this.input.focus(node),
2548
+ queries: this.queries,
2549
+ get terminalFocus() {
2550
+ return input.terminalFocus;
2551
+ }
2552
+ },
2553
+ logger,
2554
+ overlay: {
2555
+ add: (o) => this.overlay.add(o),
2556
+ remove: (o) => this.overlay.remove(o)
2557
+ },
2558
+ surface
2559
+ };
2560
+ }
2561
+ /** Mark dirty and ensure the render loop is active. Cheap and
2562
+ * idempotent — synchronous invalidations within the same tick all
2563
+ * collapse into one paint via the microtask boundary at the top of
2564
+ * the loop. */
2565
+ #schedule() {
2566
+ this.#dirty = true;
2567
+ if (!this.#running) return;
2568
+ if (this.#rendering !== void 0) return;
2569
+ this.#rendering = (async () => {
2570
+ try {
2571
+ await Promise.resolve();
2572
+ while (this.#dirty && this.#running) {
2573
+ this.#dirty = false;
2574
+ try {
2575
+ this.stats.inc("render");
2576
+ await this.#render();
2577
+ } catch (error) {
2578
+ this.#running = false;
2579
+ this.logger.child("render").error(error);
2580
+ process.nextTick(() => {
2581
+ throw error;
2582
+ });
2583
+ return;
2584
+ }
2585
+ }
2586
+ } finally {
2587
+ this.#rendering = void 0;
2588
+ }
2589
+ })();
2590
+ }
2591
+ /** Force a paint if dirty, and wait until the loop is idle. Useful
2592
+ * for tests and external "redraw and tell me when done" flows.
2593
+ * Concurrent callers share the same in-flight promise — no extra
2594
+ * paints are queued. */
2595
+ async render() {
2596
+ if (!this.#running) return;
2597
+ this.#schedule();
2598
+ if (this.#rendering !== void 0) await this.#rendering;
2599
+ }
2600
+ /** Render dirty surfaces into a shared full-viewport frame, then flush
2601
+ * queued terminal ops and final row diffs in one atomic terminal sync. */
2602
+ async #render() {
2603
+ const frame = this.frame.begin();
2604
+ const render = async (surface) => {
2605
+ if (!surface.dirty) return;
2606
+ await surface.render(frame);
2607
+ };
2608
+ await render(this.stream);
2609
+ await render(this.ui);
2610
+ frame.commitBase();
2611
+ this.selection.renderStream(frame, this.ctx);
2612
+ await render(this.overlay);
2613
+ if (this.#debug) this.#renderDebug(frame);
2614
+ this.selection.renderScreen(frame, this.ctx);
2615
+ this.terminal.flushTransmits();
2616
+ this.terminal.sync(() => {
2617
+ const s = frame.paint();
2618
+ this.stats.inc("paint-ops", s.ops);
2619
+ this.stats.inc("paint-rows", s.rows);
2620
+ });
2621
+ }
2622
+ #renderDebug(frame) {
2623
+ const stats = this.stats.get();
2624
+ const rows = Object.entries(stats).toSorted(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}: ${value}`);
2625
+ rows.unshift(`debug: ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}`);
2626
+ const width = Math.min(this.terminal.cols, Math.max(...rows.map((row) => stringWidth(row)), 0));
2627
+ const x = Math.max(1, this.terminal.cols - width + 1);
2628
+ for (let i = 0; i < rows.length && i < this.terminal.rows; i++) frame.overlay(i + 1, x, rows[i]);
2629
+ }
2630
+ #onData = (chunk) => {
2631
+ const text = typeof chunk === "string" ? chunk : String(chunk);
2632
+ for (const ev of this.#decoder.feed(text)) this.input.dispatch(ev);
2633
+ if (this.#escTimer !== void 0) clearTimeout(this.#escTimer);
2634
+ this.#escTimer = setTimeout(() => {
2635
+ this.#escTimer = void 0;
2636
+ for (const ev of this.#decoder.flush()) this.input.dispatch(ev);
2637
+ }, 25);
2638
+ this.#escTimer.unref();
2639
+ };
2640
+ };
2641
+ //#endregion
2642
+ export { Renderer };
2643
+
2644
+ //# sourceMappingURL=renderer-CxL4xbkl.mjs.map