@textui/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/adapters/index.d.ts +12 -0
  4. package/dist/adapters/index.d.ts.map +1 -0
  5. package/dist/adapters/index.js +11 -0
  6. package/dist/app/app.d.ts +200 -0
  7. package/dist/app/app.d.ts.map +1 -0
  8. package/dist/app/app.js +980 -0
  9. package/dist/core/animation.d.ts +34 -0
  10. package/dist/core/animation.d.ts.map +1 -0
  11. package/dist/core/animation.js +125 -0
  12. package/dist/core/clipboard.d.ts +29 -0
  13. package/dist/core/clipboard.d.ts.map +1 -0
  14. package/dist/core/clipboard.js +30 -0
  15. package/dist/core/commands.d.ts +42 -0
  16. package/dist/core/commands.d.ts.map +1 -0
  17. package/dist/core/commands.js +146 -0
  18. package/dist/core/components.d.ts +27 -0
  19. package/dist/core/components.d.ts.map +1 -0
  20. package/dist/core/components.js +78 -0
  21. package/dist/core/events.d.ts +23 -0
  22. package/dist/core/events.d.ts.map +1 -0
  23. package/dist/core/events.js +77 -0
  24. package/dist/core/focus.d.ts +69 -0
  25. package/dist/core/focus.d.ts.map +1 -0
  26. package/dist/core/focus.js +336 -0
  27. package/dist/core/i18n.d.ts +27 -0
  28. package/dist/core/i18n.d.ts.map +1 -0
  29. package/dist/core/i18n.js +88 -0
  30. package/dist/core/keybindings.d.ts +53 -0
  31. package/dist/core/keybindings.d.ts.map +1 -0
  32. package/dist/core/keybindings.js +163 -0
  33. package/dist/core/layers.d.ts +28 -0
  34. package/dist/core/layers.d.ts.map +1 -0
  35. package/dist/core/layers.js +84 -0
  36. package/dist/core/manifest.d.ts +22 -0
  37. package/dist/core/manifest.d.ts.map +1 -0
  38. package/dist/core/manifest.js +85 -0
  39. package/dist/core/navigation.d.ts +47 -0
  40. package/dist/core/navigation.d.ts.map +1 -0
  41. package/dist/core/navigation.js +110 -0
  42. package/dist/core/resources.d.ts +115 -0
  43. package/dist/core/resources.d.ts.map +1 -0
  44. package/dist/core/resources.js +321 -0
  45. package/dist/core/services.d.ts +21 -0
  46. package/dist/core/services.d.ts.map +1 -0
  47. package/dist/core/services.js +63 -0
  48. package/dist/core/store.d.ts +60 -0
  49. package/dist/core/store.d.ts.map +1 -0
  50. package/dist/core/store.js +593 -0
  51. package/dist/core/surfaces.d.ts +81 -0
  52. package/dist/core/surfaces.d.ts.map +1 -0
  53. package/dist/core/surfaces.js +237 -0
  54. package/dist/core/syntax.d.ts +49 -0
  55. package/dist/core/syntax.d.ts.map +1 -0
  56. package/dist/core/syntax.js +172 -0
  57. package/dist/core/when.d.ts +15 -0
  58. package/dist/core/when.d.ts.map +1 -0
  59. package/dist/core/when.js +224 -0
  60. package/dist/index.d.ts +49 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +54 -0
  63. package/dist/jsx/factory.d.ts +27 -0
  64. package/dist/jsx/factory.d.ts.map +1 -0
  65. package/dist/jsx/factory.js +102 -0
  66. package/dist/jsx/intrinsics.d.ts +96 -0
  67. package/dist/jsx/intrinsics.d.ts.map +1 -0
  68. package/dist/jsx/intrinsics.js +1 -0
  69. package/dist/jsx/jsx-dev-runtime.d.ts +13 -0
  70. package/dist/jsx/jsx-dev-runtime.d.ts.map +1 -0
  71. package/dist/jsx/jsx-dev-runtime.js +12 -0
  72. package/dist/jsx/jsx-runtime.d.ts +42 -0
  73. package/dist/jsx/jsx-runtime.d.ts.map +1 -0
  74. package/dist/jsx/jsx-runtime.js +16 -0
  75. package/dist/render/buffer.d.ts +77 -0
  76. package/dist/render/buffer.d.ts.map +1 -0
  77. package/dist/render/buffer.js +275 -0
  78. package/dist/render/color.d.ts +35 -0
  79. package/dist/render/color.d.ts.map +1 -0
  80. package/dist/render/color.js +160 -0
  81. package/dist/render/diff.d.ts +37 -0
  82. package/dist/render/diff.d.ts.map +1 -0
  83. package/dist/render/diff.js +66 -0
  84. package/dist/render/layout.d.ts +72 -0
  85. package/dist/render/layout.d.ts.map +1 -0
  86. package/dist/render/layout.js +633 -0
  87. package/dist/render/static.d.ts +63 -0
  88. package/dist/render/static.d.ts.map +1 -0
  89. package/dist/render/static.js +209 -0
  90. package/dist/runtime/bindings.d.ts +46 -0
  91. package/dist/runtime/bindings.d.ts.map +1 -0
  92. package/dist/runtime/bindings.js +116 -0
  93. package/dist/runtime/hooks.d.ts +284 -0
  94. package/dist/runtime/hooks.d.ts.map +1 -0
  95. package/dist/runtime/hooks.js +846 -0
  96. package/dist/runtime/instance.d.ts +106 -0
  97. package/dist/runtime/instance.d.ts.map +1 -0
  98. package/dist/runtime/instance.js +181 -0
  99. package/dist/runtime/paint.d.ts +27 -0
  100. package/dist/runtime/paint.d.ts.map +1 -0
  101. package/dist/runtime/paint.js +567 -0
  102. package/dist/runtime/reconcile.d.ts +23 -0
  103. package/dist/runtime/reconcile.d.ts.map +1 -0
  104. package/dist/runtime/reconcile.js +260 -0
  105. package/dist/runtime/runtime.d.ts +42 -0
  106. package/dist/runtime/runtime.d.ts.map +1 -0
  107. package/dist/runtime/runtime.js +1 -0
  108. package/dist/runtime/style.d.ts +55 -0
  109. package/dist/runtime/style.d.ts.map +1 -0
  110. package/dist/runtime/style.js +143 -0
  111. package/dist/themes/borders.d.ts +10 -0
  112. package/dist/themes/borders.d.ts.map +1 -0
  113. package/dist/themes/borders.js +88 -0
  114. package/dist/themes/builtin.d.ts +31 -0
  115. package/dist/themes/builtin.d.ts.map +1 -0
  116. package/dist/themes/builtin.js +291 -0
  117. package/dist/themes/glyphs.d.ts +15 -0
  118. package/dist/themes/glyphs.d.ts.map +1 -0
  119. package/dist/themes/glyphs.js +97 -0
  120. package/dist/themes/index.d.ts +5 -0
  121. package/dist/themes/index.d.ts.map +1 -0
  122. package/dist/themes/index.js +4 -0
  123. package/dist/themes/registry.d.ts +20 -0
  124. package/dist/themes/registry.d.ts.map +1 -0
  125. package/dist/themes/registry.js +204 -0
  126. package/dist/types/adapter.d.ts +38 -0
  127. package/dist/types/adapter.d.ts.map +1 -0
  128. package/dist/types/adapter.js +1 -0
  129. package/dist/types/animation.d.ts +31 -0
  130. package/dist/types/animation.d.ts.map +1 -0
  131. package/dist/types/animation.js +1 -0
  132. package/dist/types/app.d.ts +148 -0
  133. package/dist/types/app.d.ts.map +1 -0
  134. package/dist/types/app.js +1 -0
  135. package/dist/types/async.d.ts +30 -0
  136. package/dist/types/async.d.ts.map +1 -0
  137. package/dist/types/async.js +1 -0
  138. package/dist/types/capabilities.d.ts +44 -0
  139. package/dist/types/capabilities.d.ts.map +1 -0
  140. package/dist/types/capabilities.js +33 -0
  141. package/dist/types/cells.d.ts +72 -0
  142. package/dist/types/cells.d.ts.map +1 -0
  143. package/dist/types/cells.js +10 -0
  144. package/dist/types/command.d.ts +127 -0
  145. package/dist/types/command.d.ts.map +1 -0
  146. package/dist/types/command.js +1 -0
  147. package/dist/types/component-registry.d.ts +77 -0
  148. package/dist/types/component-registry.d.ts.map +1 -0
  149. package/dist/types/component-registry.js +1 -0
  150. package/dist/types/disposable.d.ts +8 -0
  151. package/dist/types/disposable.d.ts.map +1 -0
  152. package/dist/types/disposable.js +1 -0
  153. package/dist/types/focus.d.ts +72 -0
  154. package/dist/types/focus.d.ts.map +1 -0
  155. package/dist/types/focus.js +1 -0
  156. package/dist/types/geometry.d.ts +28 -0
  157. package/dist/types/geometry.d.ts.map +1 -0
  158. package/dist/types/geometry.js +14 -0
  159. package/dist/types/graph.d.ts +129 -0
  160. package/dist/types/graph.d.ts.map +1 -0
  161. package/dist/types/graph.js +31 -0
  162. package/dist/types/i18n.d.ts +23 -0
  163. package/dist/types/i18n.d.ts.map +1 -0
  164. package/dist/types/i18n.js +1 -0
  165. package/dist/types/index.d.ts +32 -0
  166. package/dist/types/index.d.ts.map +1 -0
  167. package/dist/types/index.js +31 -0
  168. package/dist/types/input.d.ts +65 -0
  169. package/dist/types/input.d.ts.map +1 -0
  170. package/dist/types/input.js +1 -0
  171. package/dist/types/keybinding.d.ts +49 -0
  172. package/dist/types/keybinding.d.ts.map +1 -0
  173. package/dist/types/keybinding.js +1 -0
  174. package/dist/types/layer.d.ts +54 -0
  175. package/dist/types/layer.d.ts.map +1 -0
  176. package/dist/types/layer.js +1 -0
  177. package/dist/types/manifest.d.ts +114 -0
  178. package/dist/types/manifest.d.ts.map +1 -0
  179. package/dist/types/manifest.js +1 -0
  180. package/dist/types/markdown.d.ts +58 -0
  181. package/dist/types/markdown.d.ts.map +1 -0
  182. package/dist/types/markdown.js +1 -0
  183. package/dist/types/navigation.d.ts +40 -0
  184. package/dist/types/navigation.d.ts.map +1 -0
  185. package/dist/types/navigation.js +1 -0
  186. package/dist/types/render.d.ts +72 -0
  187. package/dist/types/render.d.ts.map +1 -0
  188. package/dist/types/render.js +1 -0
  189. package/dist/types/resource.d.ts +170 -0
  190. package/dist/types/resource.d.ts.map +1 -0
  191. package/dist/types/resource.js +1 -0
  192. package/dist/types/services.d.ts +24 -0
  193. package/dist/types/services.d.ts.map +1 -0
  194. package/dist/types/services.js +3 -0
  195. package/dist/types/shell.d.ts +32 -0
  196. package/dist/types/shell.d.ts.map +1 -0
  197. package/dist/types/shell.js +1 -0
  198. package/dist/types/store.d.ts +124 -0
  199. package/dist/types/store.d.ts.map +1 -0
  200. package/dist/types/store.js +1 -0
  201. package/dist/types/stream.d.ts +18 -0
  202. package/dist/types/stream.d.ts.map +1 -0
  203. package/dist/types/stream.js +1 -0
  204. package/dist/types/style.d.ts +166 -0
  205. package/dist/types/style.d.ts.map +1 -0
  206. package/dist/types/style.js +1 -0
  207. package/dist/types/surface.d.ts +97 -0
  208. package/dist/types/surface.d.ts.map +1 -0
  209. package/dist/types/surface.js +1 -0
  210. package/dist/types/syntax.d.ts +68 -0
  211. package/dist/types/syntax.d.ts.map +1 -0
  212. package/dist/types/syntax.js +5 -0
  213. package/dist/types/terminal.d.ts +52 -0
  214. package/dist/types/terminal.d.ts.map +1 -0
  215. package/dist/types/terminal.js +1 -0
  216. package/dist/types/theme.d.ts +117 -0
  217. package/dist/types/theme.d.ts.map +1 -0
  218. package/dist/types/theme.js +1 -0
  219. package/dist/types/when.d.ts +19 -0
  220. package/dist/types/when.d.ts.map +1 -0
  221. package/dist/types/when.js +1 -0
  222. package/dist/ui/primitives.d.ts +56 -0
  223. package/dist/ui/primitives.d.ts.map +1 -0
  224. package/dist/ui/primitives.js +108 -0
  225. package/dist/ui/screen.d.ts +23 -0
  226. package/dist/ui/screen.d.ts.map +1 -0
  227. package/dist/ui/screen.js +18 -0
  228. package/dist/util/disposable.d.ts +6 -0
  229. package/dist/util/disposable.d.ts.map +1 -0
  230. package/dist/util/disposable.js +49 -0
  231. package/dist/util/markdown.d.ts +24 -0
  232. package/dist/util/markdown.d.ts.map +1 -0
  233. package/dist/util/markdown.js +220 -0
  234. package/dist/util/paths.d.ts +47 -0
  235. package/dist/util/paths.d.ts.map +1 -0
  236. package/dist/util/paths.js +134 -0
  237. package/dist/util/stream.d.ts +32 -0
  238. package/dist/util/stream.d.ts.map +1 -0
  239. package/dist/util/stream.js +206 -0
  240. package/dist/util/text.d.ts +65 -0
  241. package/dist/util/text.d.ts.map +1 -0
  242. package/dist/util/text.js +419 -0
  243. package/package.json +70 -0
  244. package/src/adapters/index.ts +11 -0
  245. package/src/app/app.ts +1096 -0
  246. package/src/core/animation.ts +144 -0
  247. package/src/core/clipboard.ts +40 -0
  248. package/src/core/commands.ts +169 -0
  249. package/src/core/components.ts +91 -0
  250. package/src/core/events.ts +95 -0
  251. package/src/core/focus.ts +358 -0
  252. package/src/core/i18n.ts +107 -0
  253. package/src/core/keybindings.ts +184 -0
  254. package/src/core/layers.ts +94 -0
  255. package/src/core/manifest.ts +84 -0
  256. package/src/core/navigation.ts +135 -0
  257. package/src/core/resources.ts +362 -0
  258. package/src/core/services.ts +69 -0
  259. package/src/core/store.ts +640 -0
  260. package/src/core/surfaces.ts +292 -0
  261. package/src/core/syntax.ts +200 -0
  262. package/src/core/when.ts +238 -0
  263. package/src/index.ts +76 -0
  264. package/src/jsx/factory.ts +124 -0
  265. package/src/jsx/intrinsics.ts +99 -0
  266. package/src/jsx/jsx-dev-runtime.ts +23 -0
  267. package/src/jsx/jsx-runtime.ts +76 -0
  268. package/src/render/buffer.ts +318 -0
  269. package/src/render/color.ts +180 -0
  270. package/src/render/diff.ts +99 -0
  271. package/src/render/layout.ts +764 -0
  272. package/src/render/static.ts +290 -0
  273. package/src/runtime/bindings.ts +150 -0
  274. package/src/runtime/hooks.ts +1057 -0
  275. package/src/runtime/instance.ts +284 -0
  276. package/src/runtime/paint.ts +737 -0
  277. package/src/runtime/reconcile.ts +335 -0
  278. package/src/runtime/runtime.ts +47 -0
  279. package/src/runtime/style.ts +205 -0
  280. package/src/themes/borders.ts +95 -0
  281. package/src/themes/builtin.ts +301 -0
  282. package/src/themes/glyphs.ts +101 -0
  283. package/src/themes/index.ts +4 -0
  284. package/src/themes/registry.ts +224 -0
  285. package/src/types/adapter.ts +41 -0
  286. package/src/types/animation.ts +34 -0
  287. package/src/types/app.ts +149 -0
  288. package/src/types/async.ts +35 -0
  289. package/src/types/capabilities.ts +79 -0
  290. package/src/types/cells.ts +79 -0
  291. package/src/types/command.ts +139 -0
  292. package/src/types/component-registry.ts +82 -0
  293. package/src/types/disposable.ts +8 -0
  294. package/src/types/focus.ts +77 -0
  295. package/src/types/geometry.ts +50 -0
  296. package/src/types/graph.ts +174 -0
  297. package/src/types/i18n.ts +25 -0
  298. package/src/types/index.ts +31 -0
  299. package/src/types/input.ts +81 -0
  300. package/src/types/keybinding.ts +51 -0
  301. package/src/types/layer.ts +46 -0
  302. package/src/types/manifest.ts +101 -0
  303. package/src/types/markdown.ts +47 -0
  304. package/src/types/navigation.ts +42 -0
  305. package/src/types/render.ts +86 -0
  306. package/src/types/resource.ts +185 -0
  307. package/src/types/services.ts +28 -0
  308. package/src/types/shell.ts +30 -0
  309. package/src/types/store.ts +148 -0
  310. package/src/types/stream.ts +24 -0
  311. package/src/types/style.ts +205 -0
  312. package/src/types/surface.ts +119 -0
  313. package/src/types/syntax.ts +93 -0
  314. package/src/types/terminal.ts +58 -0
  315. package/src/types/theme.ts +121 -0
  316. package/src/types/when.ts +21 -0
  317. package/src/ui/primitives.ts +118 -0
  318. package/src/ui/screen.ts +41 -0
  319. package/src/util/disposable.ts +49 -0
  320. package/src/util/markdown.ts +225 -0
  321. package/src/util/paths.ts +138 -0
  322. package/src/util/stream.ts +213 -0
  323. package/src/util/text.ts +428 -0
@@ -0,0 +1,1057 @@
1
+ import type { BindingPath, EventPath } from '../types/graph.js';
2
+ import type { Disposable } from '../types/disposable.js';
3
+ import type { ResolvedTheme } from '../types/theme.js';
4
+ import type { TerminalCapabilities } from '../types/capabilities.js';
5
+ import type { Rect, Size } from '../types/geometry.js';
6
+ import type { ServiceKey } from '../types/services.js';
7
+ import type { KeyEvent } from '../types/input.js';
8
+ import type { CommandDefinition } from '../types/command.js';
9
+ import { strokeOf } from '../core/keybindings.js';
10
+ import type { FocusDirection } from '../types/focus.js';
11
+ import type { TaskFn, TaskState } from '../types/async.js';
12
+ import type { Stream, StreamSource } from '../types/stream.js';
13
+ import type { TextUIApp } from '../types/app.js';
14
+ import type { I18n } from '../types/i18n.js';
15
+ import type { Navigator } from '../types/navigation.js';
16
+ import type { Resource } from '../types/resource.js';
17
+ import type { SyntaxQuery, SyntaxRegistry, SyntaxToken } from '../types/syntax.js';
18
+ import type { RenderOutput } from '../types/render.js';
19
+ import type { Instance } from './instance.js';
20
+ import type { LayoutBox } from '../render/layout.js';
21
+ import { overflowOn } from '../render/layout.js';
22
+ import type { Runtime } from './runtime.js';
23
+ import { markDirty, readContext } from './instance.js';
24
+ import { resolvePath } from '../util/paths.js';
25
+ import { toStream } from '../util/stream.js';
26
+ import { GLOBAL_SCOPE } from '../core/focus.js';
27
+ import { plainTokens } from '../core/syntax.js';
28
+ import { CLIPBOARD_PATH, readClipboard, writeClipboard } from '../core/clipboard.js';
29
+
30
+ /**
31
+ * Hooks.
32
+ *
33
+ * The rules are React's, and for the same reason: slots are matched by call
34
+ * order, so a hook behind a condition breaks the instance it lives in. What is
35
+ * different is what they reach - the store, the focus manager, the command
36
+ * registry - because those, not component state, are where a terminal
37
+ * application actually keeps things.
38
+ */
39
+
40
+ let current: Instance | null = null;
41
+
42
+ export function setCurrentInstance(instance: Instance | null): void {
43
+ current = instance;
44
+ if (instance) instance.hookIndex = 0;
45
+ }
46
+
47
+ export function currentInstance(): Instance {
48
+ if (!current) {
49
+ throw new Error('[textui] a hook was called outside a component render');
50
+ }
51
+ return current;
52
+ }
53
+
54
+ export function useRuntime(): Runtime {
55
+ return currentInstance().runtime;
56
+ }
57
+
58
+ function slot<T>(kind: string, init: () => T): { value: T; write(v: T): void; instance: Instance; index: number } {
59
+ const instance = currentInstance();
60
+ const index = instance.hookIndex++;
61
+ let entry = instance.hooks[index];
62
+
63
+ if (!entry) {
64
+ entry = { kind, value: init() };
65
+ instance.hooks[index] = entry;
66
+ } else if (entry.kind !== kind) {
67
+ throw new Error(
68
+ `[textui] hook order changed in <${instance.component}>: slot ${index} was ` +
69
+ `${entry.kind}, now ${kind}. A hook behind a condition does this.`,
70
+ );
71
+ }
72
+
73
+ return {
74
+ value: entry.value as T,
75
+ write(v: T) {
76
+ (instance.hooks[index] as { value: unknown }).value = v;
77
+ },
78
+ instance,
79
+ index,
80
+ };
81
+ }
82
+
83
+ function invalidate(instance: Instance, reason: string): void {
84
+ markDirty(instance, reason);
85
+ instance.runtime.requestRender();
86
+ }
87
+
88
+ // ------------------------------------------------------------------ state
89
+
90
+ export type SetState<T> = (next: T | ((prev: T) => T)) => void;
91
+
92
+ export function useState<T>(initial: T | (() => T)): [T, SetState<T>] {
93
+ const s = slot<{ value: T }>('state', () => ({
94
+ value: typeof initial === 'function' ? (initial as () => T)() : initial,
95
+ }));
96
+ const { instance } = s;
97
+
98
+ const set: SetState<T> = (next) => {
99
+ const box = s.value;
100
+ const value = typeof next === 'function' ? (next as (prev: T) => T)(box.value) : next;
101
+ if (Object.is(value, box.value)) return;
102
+ box.value = value;
103
+ invalidate(instance, 'useState');
104
+ };
105
+
106
+ return [s.value.value, set];
107
+ }
108
+
109
+ export function useReducer<S, A>(
110
+ reducer: (state: S, action: A) => S,
111
+ initial: S,
112
+ ): [S, (action: A) => void] {
113
+ const [state, setState] = useState<S>(initial);
114
+ return [state, (action: A) => setState((prev) => reducer(prev, action))];
115
+ }
116
+
117
+ export function useRef<T>(initial: T): { current: T } {
118
+ return slot<{ current: T }>('ref', () => ({ current: initial })).value;
119
+ }
120
+
121
+ function depsChanged(prev: unknown[] | undefined, next: unknown[] | undefined): boolean {
122
+ if (!prev || !next) return true;
123
+ if (prev.length !== next.length) return true;
124
+ for (let i = 0; i < prev.length; i++) {
125
+ if (!Object.is(prev[i], next[i])) return true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ export function useMemo<T>(factory: () => T, deps: unknown[]): T {
131
+ const instance = currentInstance();
132
+ const index = instance.hookIndex++;
133
+ const entry = instance.hooks[index];
134
+
135
+ if (!entry || entry.kind !== 'memo' || depsChanged(entry.deps, deps)) {
136
+ instance.hooks[index] = { kind: 'memo', value: factory(), deps: [...deps] };
137
+ }
138
+ return (instance.hooks[index] as { value: T }).value;
139
+ }
140
+
141
+ export function useCallback<T extends (...args: never[]) => unknown>(fn: T, deps: unknown[]): T {
142
+ return useMemo(() => fn, deps);
143
+ }
144
+
145
+ /**
146
+ * Runs after the frame is painted. The returned function runs before the next
147
+ * run and on unmount - so a subscription set up here is torn down exactly once.
148
+ */
149
+ export function useEffect(effect: () => void | (() => void), deps?: unknown[]): void {
150
+ const instance = currentInstance();
151
+ const index = instance.hookIndex++;
152
+ const entry = instance.hooks[index];
153
+ const changed = !entry || entry.kind !== 'effect' || deps === undefined || depsChanged(entry.deps, deps);
154
+
155
+ if (!changed) return;
156
+
157
+ const previousCleanup = entry?.kind === 'effect' ? entry.cleanup : undefined;
158
+ instance.hooks[index] = { kind: 'effect', value: undefined, deps: deps ? [...deps] : undefined };
159
+
160
+ instance.pendingEffects.push(() => {
161
+ if (typeof previousCleanup === 'function') {
162
+ try {
163
+ previousCleanup();
164
+ } catch (err) {
165
+ instance.runtime.onError(err, `effect cleanup in <${instance.component}>`);
166
+ }
167
+ }
168
+ try {
169
+ const cleanup = effect();
170
+ (instance.hooks[index] as { cleanup?: (() => void) | void }).cleanup = cleanup;
171
+ } catch (err) {
172
+ instance.runtime.onError(err, `effect in <${instance.component}>`);
173
+ }
174
+ });
175
+ }
176
+
177
+ /** Same contract as `useEffect`, but flushed before the frame is painted. */
178
+ export function useLayoutEffect(effect: () => void | (() => void), deps?: unknown[]): void {
179
+ useEffect(effect, deps);
180
+ }
181
+
182
+ // ---------------------------------------------------------------- context
183
+
184
+ export interface Context<T> {
185
+ id: string;
186
+ defaultValue: T;
187
+ Provider: (props: { value: T; children?: unknown }) => RenderOutput;
188
+ }
189
+
190
+ let contextCounter = 0;
191
+
192
+ export function createContext<T>(name: string, defaultValue: T): Context<T> {
193
+ const id = `${name}#${++contextCounter}`;
194
+ const ctx: Context<T> = {
195
+ id,
196
+ defaultValue,
197
+ Provider: (props: { value: T; children?: unknown }): RenderOutput => {
198
+ const instance = currentInstance();
199
+ if (!instance.contexts) instance.contexts = new Map();
200
+ instance.contexts.set(id, props.value);
201
+ return props.children as RenderOutput;
202
+ },
203
+ };
204
+ (ctx.Provider as { displayName?: string }).displayName = `${name}.Provider`;
205
+ return ctx;
206
+ }
207
+
208
+ export function useContext<T>(context: Context<T>): T {
209
+ const instance = currentInstance();
210
+ const found = readContext(instance, context.id);
211
+ return found === undefined ? context.defaultValue : (found as T);
212
+ }
213
+
214
+ // ------------------------------------------------------------------ store
215
+
216
+ /** Subscribe to a path and read it. Shared by the two store hooks. */
217
+ function useStorePath<T>(path: BindingPath): { absolute: BindingPath; value: T | undefined } {
218
+ const instance = currentInstance();
219
+ const runtime = instance.runtime;
220
+ const absolute = resolvePath(path, instance.dataContext);
221
+
222
+ const value = runtime.store.get<T>(absolute);
223
+
224
+ useEffect(() => {
225
+ const sub = runtime.store.subscribe(absolute, () => invalidate(instance, `store ${absolute}`));
226
+ /*
227
+ * And catch a write that landed between the render and this line.
228
+ *
229
+ * Subscribing happens in an effect, and effects run after the render that
230
+ * asked for them - so a component that reads a path in the same frame that
231
+ * something else writes it has already missed the notification, and will
232
+ * never hear about that value again unless it happens to change twice.
233
+ *
234
+ * It is not a rare shape: a status bar reading which panel has the
235
+ * keyboard renders before the panel's own effect publishes it, and stayed
236
+ * empty for the life of the process.
237
+ */
238
+ if (runtime.store.get<T>(absolute) !== value) invalidate(instance, `store ${absolute}`);
239
+ return () => sub.dispose();
240
+ }, [absolute]);
241
+
242
+ return { absolute, value };
243
+ }
244
+
245
+ /**
246
+ * Store-backed state, in the shape of `useState`.
247
+ *
248
+ * The second argument is an initial value, and it behaves like one: if the
249
+ * path is empty the first time a component asks for it, it is written. That is
250
+ * what makes the hook safe to read like state - every other reader of the same
251
+ * path sees the same thing, immediately, rather than each one privately
252
+ * imagining its own default.
253
+ *
254
+ * The store stays authoritative. Copying a value out into `useState` and
255
+ * editing the copy creates a second answer to a question the store already
256
+ * answers; this hook is the way to avoid needing to.
257
+ *
258
+ * For a reader that must not write - a component displaying a path that
259
+ * something else owns - use `useStoreValue`, whose second argument is a
260
+ * display fallback and nothing more.
261
+ */
262
+ export function useStore<T = unknown>(
263
+ path: BindingPath,
264
+ initial?: T,
265
+ ): [T | undefined, (value: T) => void] {
266
+ const instance = currentInstance();
267
+ const runtime = instance.runtime;
268
+ const { absolute, value } = useStorePath<T>(path);
269
+
270
+ // Seeding during render rather than in an effect is deliberate: an effect
271
+ // runs after the frame, so the first frame would paint the empty state and
272
+ // every other reader would see a hole for one frame.
273
+ if (initial !== undefined && value === undefined && !runtime.store.has(absolute)) {
274
+ runtime.store.set(absolute, initial);
275
+ return [initial, (next: T) => runtime.store.set(absolute, next)];
276
+ }
277
+
278
+ return [
279
+ value === undefined ? initial : value,
280
+ (next: T) => runtime.store.set(absolute, next),
281
+ ];
282
+ }
283
+
284
+ /**
285
+ * Read a store path without ever writing it.
286
+ *
287
+ * `fallback` is what this reader shows while the path is empty. It is not an
288
+ * initial value and it is not shared: another component reading the same path
289
+ * still sees nothing.
290
+ */
291
+ export function useStoreValue<T = unknown>(path: BindingPath, fallback?: T): T | undefined {
292
+ const { value } = useStorePath<T>(path);
293
+ return value === undefined ? fallback : value;
294
+ }
295
+
296
+ /** Subscribe to a whole subtree - a namespace, a collection, a scope. */
297
+ export function useStoreSubtree<T = unknown>(path: BindingPath): T | undefined {
298
+ const instance = currentInstance();
299
+ const runtime = instance.runtime;
300
+ const absolute = resolvePath(path, instance.dataContext);
301
+
302
+ useEffect(() => {
303
+ const sub = runtime.store.subscribe(
304
+ absolute,
305
+ () => invalidate(instance, `store subtree ${absolute}`),
306
+ { subtree: true },
307
+ );
308
+ return () => sub.dispose();
309
+ }, [absolute]);
310
+
311
+ return runtime.store.get<T>(absolute);
312
+ }
313
+
314
+ export function useCollection<T = unknown>(path: BindingPath) {
315
+ const instance = currentInstance();
316
+ const absolute = resolvePath(path, instance.dataContext);
317
+ useStoreValue(absolute);
318
+ return instance.runtime.store.collection<T>(absolute);
319
+ }
320
+
321
+ // ----------------------------------------------------------------- events
322
+
323
+ export function useEvent(
324
+ path: EventPath,
325
+ handler: (payload: unknown) => void,
326
+ options?: { subtree?: boolean },
327
+ ): void {
328
+ const runtime = useRuntime();
329
+ const ref = useRef(handler);
330
+ ref.current = handler;
331
+
332
+ useEffect(() => {
333
+ const sub = runtime.events.on(path, (payload) => ref.current(payload), options);
334
+ return () => sub.dispose();
335
+ }, [path, options?.subtree]);
336
+ }
337
+
338
+ export function useEmit(): (path: EventPath, payload?: unknown) => void {
339
+ const runtime = useRuntime();
340
+ return (path, payload) => runtime.events.emit(path, payload);
341
+ }
342
+
343
+ // -------------------------------------------------- environment and theme
344
+
345
+ export function useApp(): TextUIApp {
346
+ const app = useRuntime().app();
347
+ if (!app) {
348
+ throw new Error('[textui] useApp() outside an application - use useRuntime() instead');
349
+ }
350
+ return app;
351
+ }
352
+
353
+ export function useTheme(): ResolvedTheme {
354
+ return useRuntime().theme();
355
+ }
356
+
357
+ export function useCapabilities(): TerminalCapabilities {
358
+ return useRuntime().capabilities();
359
+ }
360
+
361
+ /** Terminal size, re-rendering on resize. */
362
+ export function useSize(): Size {
363
+ const instance = currentInstance();
364
+ const runtime = instance.runtime;
365
+
366
+ useEffect(() => {
367
+ const sub = runtime.store.subscribe(
368
+ '$/modus/size',
369
+ () => invalidate(instance, 'resize'),
370
+ { subtree: true },
371
+ );
372
+ return () => sub.dispose();
373
+ }, []);
374
+
375
+ return runtime.size();
376
+ }
377
+
378
+ /**
379
+ * Adapt to the space this component was actually given, not to the terminal.
380
+ * A sidebar and the main area are different widths on the same screen.
381
+ */
382
+ /**
383
+ * The content rect this component was last laid out into.
384
+ *
385
+ * A component that fills the space it is given cannot size itself from its
386
+ * content - a file viewer that renders one row per line makes every pane
387
+ * around it move when a different file is opened. Measuring inverts that: the
388
+ * layout decides the size, and the component renders exactly what fits.
389
+ *
390
+ * The value is the previous frame's, and asking for it schedules another pass
391
+ * when it changed, so the first frame after a resize is one frame behind and
392
+ * every frame after it is exact.
393
+ */
394
+ export function useMeasure(): Rect {
395
+ const instance = currentInstance();
396
+ measureWatchers.add(instance);
397
+ return instance.measured ?? EMPTY_RECT;
398
+ }
399
+
400
+ /**
401
+ * How big the content is, when it is bigger than the box holding it.
402
+ *
403
+ * `null` when everything fits. The layout has always recorded this - the
404
+ * comment where it does says "so a scroll container knows how far it can go" -
405
+ * but nothing read it, so no scroll container knew, and every one of them
406
+ * scrolled for ever past its own last line.
407
+ *
408
+ * Reported for the nearest *scroll container* at or below this component's own
409
+ * box - a viewport is a row holding the scrolling part beside a scrollbar, and
410
+ * the row is not the part that scrolls.
411
+ */
412
+ export function useScrollExtent(): Size | null {
413
+ const instance = currentInstance();
414
+ measureWatchers.add(instance);
415
+ return instance.scrollExtent ?? null;
416
+ }
417
+
418
+ const EMPTY_RECT: Rect = { x: 0, y: 0, width: 0, height: 0 };
419
+
420
+ /** Instances that called `useMeasure`. Pruned as they unmount. */
421
+ const measureWatchers = new Set<Instance>();
422
+
423
+ function firstHostBox(instance: Instance): LayoutBox | undefined {
424
+ if (instance.kind === 'host') return instance.box;
425
+ for (const child of instance.children) {
426
+ const found = firstHostBox(child);
427
+ if (found) return found;
428
+ }
429
+ return undefined;
430
+ }
431
+
432
+ /**
433
+ * The nearest box below this one that scrolls and has somewhere to scroll to.
434
+ *
435
+ * Not always the component's own first box: a viewport is usually a row
436
+ * holding the scrolling part beside a scrollbar, and it is the part, not the
437
+ * row, that overflows.
438
+ *
439
+ * It has to be a scroll container, not merely a box with more in it than fits.
440
+ * The layout records an extent on anything that overflows, including a row of
441
+ * text too wide for its pane - and a detail panel with one such row in it
442
+ * reported that row's width as its own scroll extent, which is a number about
443
+ * a different box on a different axis.
444
+ */
445
+ function firstScrollingBox(box: LayoutBox | undefined): LayoutBox | undefined {
446
+ if (!box) return undefined;
447
+ if (overflowOn(box.style, 'y') === 'scroll' && box.scrollSize) return box;
448
+ for (const child of box.children) {
449
+ const found = firstScrollingBox(child);
450
+ if (found) return found;
451
+ }
452
+ return undefined;
453
+ }
454
+
455
+ /**
456
+ * Publish every watcher's laid-out rect. Returns true when one changed, which
457
+ * means the frame is not final and the caller should render again.
458
+ */
459
+ export function flushMeasures(): boolean {
460
+ if (measureWatchers.size === 0) return false;
461
+
462
+ let changed = false;
463
+ for (const instance of measureWatchers) {
464
+ if (!instance.mounted) {
465
+ measureWatchers.delete(instance);
466
+ continue;
467
+ }
468
+ const box = firstHostBox(instance);
469
+ const rect = box?.content;
470
+ if (!rect) continue;
471
+
472
+ const extent = firstScrollingBox(box)?.scrollSize;
473
+ const was = instance.scrollExtent;
474
+ const extentSame = extent === undefined
475
+ ? was === undefined
476
+ : was !== undefined && was.width === extent.width && was.height === extent.height;
477
+
478
+ const previous = instance.measured;
479
+ if (
480
+ extentSame &&
481
+ previous &&
482
+ previous.x === rect.x && previous.y === rect.y &&
483
+ previous.width === rect.width && previous.height === rect.height
484
+ ) {
485
+ continue;
486
+ }
487
+ instance.measured = { ...rect };
488
+ instance.scrollExtent = extent ? { ...extent } : undefined;
489
+ markDirty(instance, 'useMeasure');
490
+ changed = true;
491
+ }
492
+ return changed;
493
+ }
494
+
495
+ export function useBreakpoint(
496
+ width: number,
497
+ breakpoints: { compact?: number; minimal?: number } = {},
498
+ ): 'full' | 'compact' | 'minimal' {
499
+ const { compact = 60, minimal = 30 } = breakpoints;
500
+ if (width < minimal) return 'minimal';
501
+ if (width < compact) return 'compact';
502
+ return 'full';
503
+ }
504
+
505
+ export function useI18n(): I18n {
506
+ const instance = currentInstance();
507
+ const runtime = instance.runtime;
508
+ useEffect(() => {
509
+ const sub = runtime.i18n.onChange(() => invalidate(instance, 'locale'));
510
+ return () => sub.dispose();
511
+ }, []);
512
+ return runtime.i18n;
513
+ }
514
+
515
+ export function useService<T>(key: ServiceKey<T>): T | undefined {
516
+ return useRuntime().services.get(key);
517
+ }
518
+
519
+ export function useRequiredService<T>(key: ServiceKey<T>): T {
520
+ return useRuntime().services.require(key);
521
+ }
522
+
523
+ // ------------------------------------------------------------------ focus
524
+
525
+ export interface UseFocusOptions {
526
+ id?: string;
527
+ disabled?: boolean;
528
+ skipTab?: boolean;
529
+ autoFocus?: boolean;
530
+ order?: number;
531
+ scopeId?: string;
532
+ onFocus?(): void;
533
+ onBlur?(): void;
534
+ }
535
+
536
+ export interface FocusHandle {
537
+ id: string;
538
+ focused: boolean;
539
+ focus(): void;
540
+ blur(): void;
541
+ move(direction: FocusDirection): void;
542
+ }
543
+
544
+ /**
545
+ * The context key a focus scope publishes itself under.
546
+ *
547
+ * Not a `createContext` value because nothing renders a provider: a scope is
548
+ * declared by a hook inside the component that owns it, and every focusable
549
+ * below it has to inherit the scope without being wrapped in anything.
550
+ */
551
+ const FOCUS_SCOPE_CONTEXT = 'textui.focusScope';
552
+
553
+ /** The focus scope this instance sits inside, if any. */
554
+ export function focusScopeOf(instance: Instance): string | undefined {
555
+ const found = readContext(instance, FOCUS_SCOPE_CONTEXT);
556
+ return typeof found === 'string' ? found : undefined;
557
+ }
558
+
559
+ export function useFocus(options: UseFocusOptions = {}): FocusHandle {
560
+ const instance = currentInstance();
561
+ const runtime = instance.runtime;
562
+ const idRef = useRef(options.id ?? `${instance.id}:focus`);
563
+ const id = options.id ?? idRef.current;
564
+
565
+ // A control inside a dialog belongs to the dialog's scope. Registering in
566
+ // the global one instead is invisible until something traps focus, and then
567
+ // tab stops working entirely: the trap filters the tab order down to its own
568
+ // scope, and every control it contains has been filed somewhere else.
569
+ const scopeId = options.scopeId ?? focusScopeOf(instance);
570
+
571
+ useEffect(() => {
572
+ const registration = runtime.focus.register({
573
+ id,
574
+ disabled: options.disabled,
575
+ skipTab: options.skipTab,
576
+ order: options.order,
577
+ scopeId,
578
+ onFocus: () => {
579
+ invalidate(instance, 'focus');
580
+ options.onFocus?.();
581
+ },
582
+ onBlur: () => {
583
+ invalidate(instance, 'blur');
584
+ options.onBlur?.();
585
+ },
586
+ });
587
+ // `autoFocus` claims focus, it does not steal it. A prompt dialog has an
588
+ // auto-focused field *and* a default button, and whichever mounted last
589
+ // would otherwise win - which is how a text field ends up unfocused in the
590
+ // dialog that exists to ask for text.
591
+ if (options.autoFocus) {
592
+ const current = runtime.focus.focused();
593
+ const claimed = current !== null
594
+ && runtime.focus.scopeOf(current) === (scopeId ?? GLOBAL_SCOPE);
595
+ if (!claimed) runtime.focus.focus(id);
596
+ }
597
+ return () => registration.dispose();
598
+ // Only identity is a reason to register again. `disabled`, `skipTab` and
599
+ // `order` are *state on* a focusable, not a different focusable - and
600
+ // re-registering to change one costs the control its place in the tab
601
+ // order, because a registration that was disposed and made again goes on
602
+ // the end. A Submit button that is disabled until a field is filled in
603
+ // therefore ended up after Cancel the moment it became usable, which is
604
+ // the one control the reader was tabbing towards.
605
+ }, [id, scopeId]);
606
+
607
+ // The mutable half, pushed rather than re-registered.
608
+ useEffect(() => {
609
+ if (!runtime.focus.has(id)) return;
610
+ runtime.focus.update(id, {
611
+ disabled: options.disabled,
612
+ skipTab: options.skipTab,
613
+ order: options.order,
614
+ });
615
+ }, [id, options.disabled, options.skipTab, options.order]);
616
+
617
+ return {
618
+ id,
619
+ focused: runtime.focus.focused() === id,
620
+ focus: () => runtime.focus.focus(id),
621
+ blur: () => runtime.focus.blur(),
622
+ move: (direction) => runtime.focus.move(direction),
623
+ };
624
+ }
625
+
626
+ /** A focus scope. Modals trap; a sidebar does not. */
627
+ export function useFocusScope(options: { id?: string; trap?: boolean; restore?: boolean; autoFocus?: boolean; active?: boolean } = {}): string {
628
+ const instance = currentInstance();
629
+ const runtime = instance.runtime;
630
+ const idRef = useRef(options.id ?? `${instance.id}:scope`);
631
+ const id = options.id ?? idRef.current;
632
+ const active = options.active ?? true;
633
+
634
+ // Publish to the subtree during render, before any descendant registers.
635
+ if (!instance.contexts) instance.contexts = new Map();
636
+ instance.contexts.set(FOCUS_SCOPE_CONTEXT, id);
637
+
638
+ useEffect(() => {
639
+ const registration = runtime.focus.registerScope({
640
+ id,
641
+ trap: options.trap,
642
+ restore: options.restore,
643
+ autoFocus: options.autoFocus,
644
+ });
645
+ if (active) runtime.focus.activateScope(id);
646
+ return () => {
647
+ runtime.focus.deactivateScope(id);
648
+ registration.dispose();
649
+ };
650
+ }, [id, options.trap, options.restore, active]);
651
+
652
+ return id;
653
+ }
654
+
655
+ /**
656
+ * Keyboard input. Scoped to focus by default - a handler that fires while
657
+ * something else is focused is nearly always a bug, so `global` has to be
658
+ * asked for.
659
+ */
660
+ /**
661
+ * A key that is not this control's to take.
662
+ *
663
+ * A list handles `pagedown`, and `ctrl+pagedown` is an application saying
664
+ * "next file" over the top of it - one is navigation inside the control, the
665
+ * other is a chord aimed past it. A control that switches on `event.name`
666
+ * alone takes both, and the application's binding then works everywhere except
667
+ * in the pane a person is actually looking at.
668
+ */
669
+ export function chorded(event: KeyEvent): boolean {
670
+ return event.ctrl || event.alt || event.meta;
671
+ }
672
+
673
+ export function useInput(
674
+ handler: (event: KeyEvent) => boolean | void,
675
+ options: { focusId?: string; global?: boolean; enabled?: boolean } = {},
676
+ ): void {
677
+ const instance = currentInstance();
678
+ const runtime = instance.runtime;
679
+ const ref = useRef(handler);
680
+ ref.current = handler;
681
+
682
+ const enabled = options.enabled ?? true;
683
+ const focusId = options.focusId ?? `${instance.id}:focus`;
684
+
685
+ useEffect(() => {
686
+ if (!enabled) return;
687
+ const onKey = (event: KeyEvent): boolean | void => ref.current(event);
688
+
689
+ // A global handler is its own node. A scoped one attaches to the focusable
690
+ // this component already registered - re-registering the same id would
691
+ // replace it, quietly dropping its tab order and its focus callbacks.
692
+ if (options.global) {
693
+ // "Global" means the handler is not tied to a focusable, not that it
694
+ // outranks a modal. A component inside a trapping scope - or one that
695
+ // opened the trap itself, like the palette - files its handler there, or
696
+ // the trap that owns the keyboard would exclude the very keys the layer
697
+ // exists to read.
698
+ const registration = runtime.focus.register({
699
+ id: `${instance.id}:global`,
700
+ skipTab: true,
701
+ global: true,
702
+ scopeId: focusScopeOf(instance) ?? '__global__',
703
+ onKey,
704
+ });
705
+ return () => registration.dispose();
706
+ }
707
+
708
+ if (runtime.focus.has(focusId)) {
709
+ runtime.focus.update(focusId, { onKey });
710
+ return () => runtime.focus.update(focusId, { onKey: undefined });
711
+ }
712
+
713
+ const registration = runtime.focus.register({ id: focusId, onKey });
714
+ return () => registration.dispose();
715
+ }, [enabled, focusId, options.global]);
716
+ }
717
+
718
+ // --------------------------------------------------------------- commands
719
+
720
+ /**
721
+ * The screen this is drawn inside: which one, and what it was given.
722
+ *
723
+ * Reads the published entry rather than props, so a control eight levels down
724
+ * can ask which task it is showing without every box between it and the screen
725
+ * forwarding an id it does not care about.
726
+ */
727
+ /**
728
+ * Keys, by the name they are written under everywhere else.
729
+ *
730
+ * `useInput` hands you a `KeyEvent` and leaves you to compare its fields,
731
+ * which is four lines of `event.ctrl && event.name === 's'` per key and a bug
732
+ * the first time somebody forgets that shift is implied by an uppercase
733
+ * letter. The keybinding registry already had the answer - one canonical
734
+ * spelling per stroke - so this reads the same strings.
735
+ *
736
+ * useKeymap({
737
+ * '+': () => setCount((c) => c + 1),
738
+ * '-': () => setCount((c) => c - 1),
739
+ * space: () => setRunning((r) => !r),
740
+ * 'ctrl+s': save,
741
+ * });
742
+ *
743
+ * Global by default, unlike `useInput`. A component that lists the keys it
744
+ * wants almost never also wants them to stop working the moment focus lands
745
+ * somewhere else - and the screens where that is wrong have a focusable to
746
+ * name, so they can say `{ global: false }` and mean it.
747
+ *
748
+ * A handler that returns nothing has handled the key. Return `false` to let
749
+ * it carry on to whatever is behind.
750
+ */
751
+ export function useKeymap(
752
+ map: Record<string, (event: KeyEvent) => boolean | void>,
753
+ options: { focusId?: string; global?: boolean; enabled?: boolean } = {},
754
+ ): void {
755
+ const ref = useRef(map);
756
+ ref.current = map;
757
+
758
+ useInput((event) => {
759
+ const handler = ref.current[strokeOf(event)];
760
+ if (!handler) return;
761
+ // Silence is consent: a key you named is a key you meant to take.
762
+ return handler(event) ?? true;
763
+ }, { ...options, global: options.global ?? true });
764
+ }
765
+
766
+ export function useScreen<P = Record<string, unknown>>(): { id: string | null; params: P } {
767
+ const id = useStoreValue<string | null>('$/layout/screen/current' as BindingPath, null);
768
+ const params = useStoreValue<P>('$/layout/screen/params' as BindingPath);
769
+ return { id: id ?? null, params: (params ?? {}) as P };
770
+ }
771
+
772
+ /** The stack, for a component that moves between screens. */
773
+ export function useNavigate(): Navigator {
774
+ return useApp().screens;
775
+ }
776
+
777
+ export function useCommand(def: Omit<CommandDefinition, 'scopeId'>, deps: unknown[] = []): void {
778
+ const instance = currentInstance();
779
+ const runtime = instance.runtime;
780
+ const app = runtime.app();
781
+
782
+ useEffect(() => {
783
+ if (!app) return;
784
+ const registration = app.commands.register({ ...def, scopeId: instance.id });
785
+ return () => registration.dispose();
786
+ }, [def.id, ...deps]);
787
+ }
788
+
789
+ export function useExecute(): (id: string, args?: Record<string, unknown>) => unknown {
790
+ const runtime = useRuntime();
791
+ return (id, args) => runtime.execute(id, args);
792
+ }
793
+
794
+ // ------------------------------------------------------------------ async
795
+
796
+ export interface TaskHandle<T> extends TaskState<T> {
797
+ run(...args: unknown[]): Promise<T | undefined>;
798
+ cancel(): void;
799
+ reset(): void;
800
+ }
801
+
802
+ /**
803
+ * An async unit of work with a lifecycle a component can render: idle,
804
+ * running, success, error, cancelled - plus progress and cancellation.
805
+ */
806
+ export function useTask<T>(fn: TaskFn<T>, deps: unknown[] = []): TaskHandle<T> {
807
+ const instance = currentInstance();
808
+ const [state, setState] = useState<TaskState<T>>({ status: 'idle' });
809
+ const controller = useRef<AbortController | null>(null);
810
+ const fnRef = useRef(fn);
811
+ fnRef.current = fn;
812
+
813
+ useEffect(() => () => controller.current?.abort(), []);
814
+
815
+ const run = useCallback(
816
+ async (...args: unknown[]): Promise<T | undefined> => {
817
+ controller.current?.abort();
818
+ const ac = new AbortController();
819
+ controller.current = ac;
820
+
821
+ setState({ status: 'running', startedAt: Date.now() });
822
+ try {
823
+ const value = await fnRef.current(
824
+ {
825
+ signal: ac.signal,
826
+ progress: (progress, step) =>
827
+ setState((prev) => ({ ...prev, progress, step })),
828
+ },
829
+ ...args,
830
+ );
831
+ if (ac.signal.aborted) {
832
+ setState((prev) => ({ ...prev, status: 'cancelled', finishedAt: Date.now() }));
833
+ return undefined;
834
+ }
835
+ setState({ status: 'success', data: value, finishedAt: Date.now() });
836
+ return value;
837
+ } catch (error) {
838
+ if (ac.signal.aborted) {
839
+ setState((prev) => ({ ...prev, status: 'cancelled', finishedAt: Date.now() }));
840
+ return undefined;
841
+ }
842
+ setState({ status: 'error', error, finishedAt: Date.now() });
843
+ instance.runtime.onError(error, `task in <${instance.component}>`);
844
+ return undefined;
845
+ }
846
+ },
847
+ deps,
848
+ ) as (...args: unknown[]) => Promise<T | undefined>;
849
+
850
+ return {
851
+ ...state,
852
+ run,
853
+ cancel: () => controller.current?.abort(),
854
+ reset: () => setState({ status: 'idle' }),
855
+ };
856
+ }
857
+
858
+ /** A task that runs on mount and can be refreshed. */
859
+ export function useResource<T>(
860
+ fn: TaskFn<T>,
861
+ deps: unknown[] = [],
862
+ ): TaskHandle<T> & { refresh(): void } {
863
+ const task = useTask(fn, deps);
864
+ useEffect(() => {
865
+ void task.run();
866
+ }, deps);
867
+ return { ...task, refresh: () => void task.run() };
868
+ }
869
+
870
+ /** Read a resource through the registry, by URI. */
871
+ export function useResourceUri(uri: string | null): {
872
+ resource: Resource | null;
873
+ content: string | Uint8Array | null;
874
+ status: TaskState['status'];
875
+ error: unknown;
876
+ refresh(): void;
877
+ } {
878
+ const app = useRuntime().app();
879
+ const task = useResource(async () => {
880
+ if (!uri || !app) return null;
881
+ const resource = await app.resources.stat(uri);
882
+ if (!resource) return null;
883
+ const content = resource.capabilities.includes('read')
884
+ ? await app.resources.read(uri)
885
+ : null;
886
+ return { resource, content };
887
+ }, [uri]);
888
+
889
+ return {
890
+ resource: task.data?.resource ?? null,
891
+ content: task.data?.content ?? null,
892
+ status: task.status,
893
+ error: task.error,
894
+ refresh: task.refresh,
895
+ };
896
+ }
897
+
898
+ // ---------------------------------------------------------------- syntax
899
+
900
+ /** The highlighter registry, or undefined outside an application. */
901
+ export function useSyntax(): SyntaxRegistry | undefined {
902
+ return useRuntime().app()?.syntax;
903
+ }
904
+
905
+ /**
906
+ * Tokenise text for display, memoised on the text and the query.
907
+ *
908
+ * Nothing registered for this kind means one plain token per line, which is
909
+ * exactly what an uncoloured viewer wants - so a caller never branches on
910
+ * whether highlighting exists.
911
+ */
912
+ export function useHighlight(text: string, query: SyntaxQuery = {}): SyntaxToken[][] {
913
+ const syntax = useSyntax();
914
+ const { kind, uri, language } = query;
915
+ return useMemo(
916
+ () => (syntax ? syntax.tokenize(text, { kind, uri, language }) : plainTokens(text)),
917
+ [syntax, text, kind, uri, language],
918
+ );
919
+ }
920
+
921
+ // -------------------------------------------------------------- clipboard
922
+
923
+ export interface ClipboardHandle {
924
+ /** What is on the clipboard now. Reading it here means a menu row that
925
+ * offers "Paste" redraws when there is something to paste. */
926
+ text: string;
927
+ read(): string;
928
+ write(text: string): void;
929
+ }
930
+
931
+ /**
932
+ * The clipboard, as a hook.
933
+ *
934
+ * `write` puts the text on the system clipboard as well, when the terminal
935
+ * can take it. Nothing reads the system clipboard back - see
936
+ * `core/clipboard.ts` for why - so a paste is whatever this application last
937
+ * copied, plus whatever the terminal delivers as a bracketed paste.
938
+ */
939
+ export function useClipboard(): ClipboardHandle {
940
+ const runtime = useRuntime();
941
+ const text = useStoreValue<string>(CLIPBOARD_PATH, '') ?? '';
942
+ return {
943
+ text,
944
+ read: () => readClipboard(runtime.store),
945
+ write: (next: string) => writeClipboard(runtime.store, next, runtime.app()?.terminal),
946
+ };
947
+ }
948
+
949
+ // ---------------------------------------------------------------- streams
950
+
951
+ /** The most recent `limit` values from any stream source. */
952
+ export function useStream<T>(
953
+ source: StreamSource<T> | null,
954
+ options: { limit?: number } = {},
955
+ ): T[] {
956
+ const instance = currentInstance();
957
+ const limit = options.limit ?? 500;
958
+ const buffer = useRef<T[]>([]);
959
+
960
+ useEffect(() => {
961
+ if (!source) return;
962
+ buffer.current = [];
963
+ const stream: Stream<T> = toStream(source);
964
+ const sub = stream.subscribe({
965
+ next(value) {
966
+ buffer.current.push(value);
967
+ if (buffer.current.length > limit) {
968
+ buffer.current.splice(0, buffer.current.length - limit);
969
+ }
970
+ invalidate(instance, 'stream');
971
+ },
972
+ error(err) {
973
+ instance.runtime.onError(err, `stream in <${instance.component}>`);
974
+ },
975
+ });
976
+ return () => sub.dispose();
977
+ }, [source, limit]);
978
+
979
+ return buffer.current;
980
+ }
981
+
982
+ // -------------------------------------------------------------- animation
983
+
984
+ /** A frame ticker, throttled and globally disableable by the driver. */
985
+ export function useTicker(
986
+ onTick: (frame: number, elapsedMs: number) => void,
987
+ options: { fps?: number; enabled?: boolean } = {},
988
+ ): void {
989
+ const runtime = useRuntime();
990
+ const ref = useRef(onTick);
991
+ ref.current = onTick;
992
+ const enabled = options.enabled ?? true;
993
+
994
+ useEffect(() => {
995
+ if (!enabled) return;
996
+ const ticker = runtime.animation.ticker({
997
+ fps: options.fps,
998
+ onTick: (frame, elapsed) => ref.current(frame, elapsed),
999
+ });
1000
+ return () => ticker.dispose();
1001
+ }, [enabled, options.fps]);
1002
+ }
1003
+
1004
+ /** A frame counter, for spinners and marquees. Frozen when animation is off. */
1005
+ export function useFrame(fps = 10): number {
1006
+ const instance = currentInstance();
1007
+ const [frame, setFrame] = useState(0);
1008
+ const disabled = instance.runtime.animation.disabled;
1009
+
1010
+ useTicker(() => setFrame((f) => f + 1), { fps, enabled: !disabled });
1011
+ return disabled ? 0 : frame;
1012
+ }
1013
+
1014
+ /** A value that eases towards its target. Snaps when animation is off. */
1015
+ export function useTween(target: number, durationMs = 200): number {
1016
+ const runtime = useRuntime();
1017
+ const [value, setValue] = useState(target);
1018
+ const from = useRef(target);
1019
+
1020
+ useEffect(() => {
1021
+ if (runtime.animation.disabled || durationMs <= 0) {
1022
+ from.current = target;
1023
+ setValue(target);
1024
+ return;
1025
+ }
1026
+ const tween = runtime.animation.tween({
1027
+ from: from.current,
1028
+ to: target,
1029
+ durationMs,
1030
+ onUpdate: (v) => setValue(v),
1031
+ onComplete: () => { from.current = target; },
1032
+ });
1033
+ return () => tween.dispose();
1034
+ }, [target, durationMs]);
1035
+
1036
+ return runtime.animation.disabled ? target : value;
1037
+ }
1038
+
1039
+ export function useInterval(fn: () => void, ms: number, enabled = true): void {
1040
+ const ref = useRef(fn);
1041
+ ref.current = fn;
1042
+
1043
+ useEffect(() => {
1044
+ if (!enabled || ms <= 0) return;
1045
+ const timer = setInterval(() => ref.current(), ms);
1046
+ (timer as unknown as { unref?: () => void }).unref?.();
1047
+ return () => clearInterval(timer);
1048
+ }, [ms, enabled]);
1049
+ }
1050
+
1051
+ /** Register a disposable for the lifetime of this component. */
1052
+ export function useDisposable(factory: () => Disposable, deps: unknown[] = []): void {
1053
+ useEffect(() => {
1054
+ const d = factory();
1055
+ return () => d.dispose();
1056
+ }, deps);
1057
+ }