@renxqoo/renx-code 0.0.2 → 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 (298) hide show
  1. package/README.md +59 -223
  2. package/bin/renx.cjs +34 -0
  3. package/package.json +27 -83
  4. package/src/App.tsx +297 -0
  5. package/src/agent/runtime/event-format.ts +258 -0
  6. package/src/agent/runtime/model-types.ts +13 -0
  7. package/src/agent/runtime/runtime.context-usage.test.ts +193 -0
  8. package/src/agent/runtime/runtime.error-handling.test.ts +236 -0
  9. package/src/agent/runtime/runtime.simple.test.ts +16 -0
  10. package/src/agent/runtime/runtime.test.ts +293 -0
  11. package/src/agent/runtime/runtime.ts +881 -0
  12. package/src/agent/runtime/runtime.usage-forwarding.test.ts +229 -0
  13. package/src/agent/runtime/source-modules.test.ts +57 -0
  14. package/src/agent/runtime/source-modules.ts +353 -0
  15. package/src/agent/runtime/tool-call-buffer.test.ts +65 -0
  16. package/src/agent/runtime/tool-call-buffer.ts +60 -0
  17. package/src/agent/runtime/tool-confirmation.test.ts +56 -0
  18. package/src/agent/runtime/tool-confirmation.ts +15 -0
  19. package/src/agent/runtime/types.ts +99 -0
  20. package/src/commands/slash-commands.test.ts +216 -0
  21. package/src/commands/slash-commands.ts +64 -0
  22. package/src/components/chat/assistant-reply.test.tsx +47 -0
  23. package/src/components/chat/assistant-reply.tsx +136 -0
  24. package/src/components/chat/assistant-segment.test.ts +99 -0
  25. package/src/components/chat/assistant-segment.tsx +125 -0
  26. package/src/components/chat/assistant-tool-group.tsx +900 -0
  27. package/src/components/chat/code-block.test.tsx +206 -0
  28. package/src/components/chat/code-block.tsx +313 -0
  29. package/src/components/chat/prompt-card.tsx +81 -0
  30. package/src/components/chat/segment-groups.test.ts +52 -0
  31. package/src/components/chat/segment-groups.ts +106 -0
  32. package/src/components/chat/turn-item.tsx +39 -0
  33. package/src/components/conversation-panel.tsx +43 -0
  34. package/src/components/file-mention-menu.tsx +77 -0
  35. package/src/components/file-picker-dialog.tsx +206 -0
  36. package/src/components/footer-hints.tsx +75 -0
  37. package/src/components/model-picker-dialog.tsx +248 -0
  38. package/src/components/prompt.tsx +233 -0
  39. package/src/components/slash-command-menu.tsx +65 -0
  40. package/src/components/tool-confirm-dialog-content.test.ts +103 -0
  41. package/src/components/tool-confirm-dialog-content.ts +186 -0
  42. package/src/components/tool-confirm-dialog.tsx +187 -0
  43. package/src/components/tool-display-config.ts +119 -0
  44. package/src/context-usage-regressions.test.ts +26 -0
  45. package/src/files/attachment-capabilities.test.ts +30 -0
  46. package/src/files/attachment-capabilities.ts +50 -0
  47. package/src/files/attachment-content.ts +153 -0
  48. package/src/files/file-mention-query.test.ts +34 -0
  49. package/src/files/file-mention-query.ts +32 -0
  50. package/src/files/prompt-display.ts +13 -0
  51. package/src/files/types.ts +5 -0
  52. package/src/files/workspace-files.ts +63 -0
  53. package/src/hooks/agent-event-handlers.test.ts +207 -0
  54. package/src/hooks/agent-event-handlers.ts +196 -0
  55. package/src/hooks/chat-local-replies.fixed.test.ts +119 -0
  56. package/src/hooks/chat-local-replies.test.ts +153 -0
  57. package/src/hooks/chat-local-replies.ts +63 -0
  58. package/src/hooks/turn-updater.test.ts +70 -0
  59. package/src/hooks/turn-updater.ts +166 -0
  60. package/src/hooks/use-agent-chat.context.test.ts +10 -0
  61. package/src/hooks/use-agent-chat.status.test.ts +14 -0
  62. package/src/hooks/use-agent-chat.test.ts +80 -0
  63. package/src/hooks/use-agent-chat.ts +621 -0
  64. package/src/hooks/use-file-mention-menu.ts +196 -0
  65. package/src/hooks/use-file-picker.ts +185 -0
  66. package/src/hooks/use-model-picker.ts +196 -0
  67. package/src/hooks/use-slash-command-menu.ts +154 -0
  68. package/src/index.tsx +55 -0
  69. package/src/runtime/clipboard.test.ts +43 -0
  70. package/src/runtime/clipboard.ts +89 -0
  71. package/src/runtime/exit.test.ts +177 -0
  72. package/src/runtime/exit.ts +98 -0
  73. package/src/runtime/runtime-support.test.ts +31 -0
  74. package/src/runtime/terminal-theme.test.ts +55 -0
  75. package/src/runtime/terminal-theme.ts +196 -0
  76. package/src/types/chat.ts +32 -0
  77. package/src/types/message-content.ts +48 -0
  78. package/src/ui/open-code-theme.ts +176 -0
  79. package/src/ui/opencode-markdown.ts +211 -0
  80. package/src/ui/theme.simple.test.ts +52 -0
  81. package/src/ui/theme.test.ts +151 -0
  82. package/src/ui/theme.ts +152 -0
  83. package/src/utils/time.test.ts +144 -0
  84. package/src/utils/time.ts +7 -0
  85. package/tsconfig.json +30 -0
  86. package/LICENSE +0 -21
  87. package/dist/App.d.ts +0 -2
  88. package/dist/App.d.ts.map +0 -1
  89. package/dist/App.js +0 -170
  90. package/dist/App.js.map +0 -1
  91. package/dist/agent/prompts/system.d.ts +0 -24
  92. package/dist/agent/prompts/system.d.ts.map +0 -1
  93. package/dist/agent/prompts/system.js +0 -222
  94. package/dist/agent/prompts/system.js.map +0 -1
  95. package/dist/agent/runtime/event-format.d.ts +0 -17
  96. package/dist/agent/runtime/event-format.d.ts.map +0 -1
  97. package/dist/agent/runtime/event-format.js +0 -194
  98. package/dist/agent/runtime/event-format.js.map +0 -1
  99. package/dist/agent/runtime/model-types.d.ts +0 -13
  100. package/dist/agent/runtime/model-types.d.ts.map +0 -1
  101. package/dist/agent/runtime/model-types.js +0 -1
  102. package/dist/agent/runtime/model-types.js.map +0 -1
  103. package/dist/agent/runtime/runtime.d.ts +0 -16
  104. package/dist/agent/runtime/runtime.d.ts.map +0 -1
  105. package/dist/agent/runtime/runtime.js +0 -691
  106. package/dist/agent/runtime/runtime.js.map +0 -1
  107. package/dist/agent/runtime/source-modules.d.ts +0 -176
  108. package/dist/agent/runtime/source-modules.d.ts.map +0 -1
  109. package/dist/agent/runtime/source-modules.js +0 -110
  110. package/dist/agent/runtime/source-modules.js.map +0 -1
  111. package/dist/agent/runtime/tool-call-buffer.d.ts +0 -12
  112. package/dist/agent/runtime/tool-call-buffer.d.ts.map +0 -1
  113. package/dist/agent/runtime/tool-call-buffer.js +0 -48
  114. package/dist/agent/runtime/tool-call-buffer.js.map +0 -1
  115. package/dist/agent/runtime/tool-confirmation.d.ts +0 -3
  116. package/dist/agent/runtime/tool-confirmation.d.ts.map +0 -1
  117. package/dist/agent/runtime/tool-confirmation.js +0 -9
  118. package/dist/agent/runtime/tool-confirmation.js.map +0 -1
  119. package/dist/agent/runtime/types.d.ts +0 -86
  120. package/dist/agent/runtime/types.d.ts.map +0 -1
  121. package/dist/agent/runtime/types.js +0 -1
  122. package/dist/agent/runtime/types.js.map +0 -1
  123. package/dist/cli.d.ts +0 -3
  124. package/dist/cli.d.ts.map +0 -1
  125. package/dist/cli.js +0 -43
  126. package/dist/cli.js.map +0 -1
  127. package/dist/commands/slash-commands.d.ts +0 -11
  128. package/dist/commands/slash-commands.d.ts.map +0 -1
  129. package/dist/commands/slash-commands.js +0 -48
  130. package/dist/commands/slash-commands.js.map +0 -1
  131. package/dist/components/chat/assistant-reply.d.ts +0 -13
  132. package/dist/components/chat/assistant-reply.d.ts.map +0 -1
  133. package/dist/components/chat/assistant-reply.js +0 -78
  134. package/dist/components/chat/assistant-reply.js.map +0 -1
  135. package/dist/components/chat/assistant-segment.d.ts +0 -8
  136. package/dist/components/chat/assistant-segment.d.ts.map +0 -1
  137. package/dist/components/chat/assistant-segment.js +0 -54
  138. package/dist/components/chat/assistant-segment.js.map +0 -1
  139. package/dist/components/chat/assistant-tool-group.d.ts +0 -7
  140. package/dist/components/chat/assistant-tool-group.d.ts.map +0 -1
  141. package/dist/components/chat/assistant-tool-group.js +0 -695
  142. package/dist/components/chat/assistant-tool-group.js.map +0 -1
  143. package/dist/components/chat/code-block.d.ts +0 -16
  144. package/dist/components/chat/code-block.d.ts.map +0 -1
  145. package/dist/components/chat/code-block.js +0 -194
  146. package/dist/components/chat/code-block.js.map +0 -1
  147. package/dist/components/chat/prompt-card.d.ts +0 -9
  148. package/dist/components/chat/prompt-card.d.ts.map +0 -1
  149. package/dist/components/chat/prompt-card.js +0 -18
  150. package/dist/components/chat/prompt-card.js.map +0 -1
  151. package/dist/components/chat/segment-groups.d.ts +0 -24
  152. package/dist/components/chat/segment-groups.d.ts.map +0 -1
  153. package/dist/components/chat/segment-groups.js +0 -69
  154. package/dist/components/chat/segment-groups.js.map +0 -1
  155. package/dist/components/chat/turn-item.d.ts +0 -9
  156. package/dist/components/chat/turn-item.d.ts.map +0 -1
  157. package/dist/components/chat/turn-item.js +0 -11
  158. package/dist/components/chat/turn-item.js.map +0 -1
  159. package/dist/components/conversation-panel.d.ts +0 -8
  160. package/dist/components/conversation-panel.d.ts.map +0 -1
  161. package/dist/components/conversation-panel.js +0 -8
  162. package/dist/components/conversation-panel.js.map +0 -1
  163. package/dist/components/file-mention-menu.d.ts +0 -11
  164. package/dist/components/file-mention-menu.d.ts.map +0 -1
  165. package/dist/components/file-mention-menu.js +0 -15
  166. package/dist/components/file-mention-menu.js.map +0 -1
  167. package/dist/components/file-picker-dialog.d.ts +0 -21
  168. package/dist/components/file-picker-dialog.d.ts.map +0 -1
  169. package/dist/components/file-picker-dialog.js +0 -48
  170. package/dist/components/file-picker-dialog.js.map +0 -1
  171. package/dist/components/footer-hints.d.ts +0 -7
  172. package/dist/components/footer-hints.d.ts.map +0 -1
  173. package/dist/components/footer-hints.js +0 -29
  174. package/dist/components/footer-hints.js.map +0 -1
  175. package/dist/components/model-picker-dialog.d.ts +0 -20
  176. package/dist/components/model-picker-dialog.d.ts.map +0 -1
  177. package/dist/components/model-picker-dialog.js +0 -72
  178. package/dist/components/model-picker-dialog.js.map +0 -1
  179. package/dist/components/prompt.d.ts +0 -18
  180. package/dist/components/prompt.d.ts.map +0 -1
  181. package/dist/components/prompt.js +0 -96
  182. package/dist/components/prompt.js.map +0 -1
  183. package/dist/components/slash-command-menu.d.ts +0 -9
  184. package/dist/components/slash-command-menu.d.ts.map +0 -1
  185. package/dist/components/slash-command-menu.js +0 -20
  186. package/dist/components/slash-command-menu.js.map +0 -1
  187. package/dist/components/tool-confirm-dialog-content.d.ts +0 -15
  188. package/dist/components/tool-confirm-dialog-content.d.ts.map +0 -1
  189. package/dist/components/tool-confirm-dialog-content.js +0 -143
  190. package/dist/components/tool-confirm-dialog-content.js.map +0 -1
  191. package/dist/components/tool-confirm-dialog.d.ts +0 -12
  192. package/dist/components/tool-confirm-dialog.d.ts.map +0 -1
  193. package/dist/components/tool-confirm-dialog.js +0 -21
  194. package/dist/components/tool-confirm-dialog.js.map +0 -1
  195. package/dist/components/tool-display-config.d.ts +0 -11
  196. package/dist/components/tool-display-config.d.ts.map +0 -1
  197. package/dist/components/tool-display-config.js +0 -94
  198. package/dist/components/tool-display-config.js.map +0 -1
  199. package/dist/config/paths.d.ts +0 -7
  200. package/dist/config/paths.d.ts.map +0 -1
  201. package/dist/config/paths.js +0 -24
  202. package/dist/config/paths.js.map +0 -1
  203. package/dist/files/attachment-capabilities.d.ts +0 -19
  204. package/dist/files/attachment-capabilities.d.ts.map +0 -1
  205. package/dist/files/attachment-capabilities.js +0 -26
  206. package/dist/files/attachment-capabilities.js.map +0 -1
  207. package/dist/files/attachment-content.d.ts +0 -5
  208. package/dist/files/attachment-content.d.ts.map +0 -1
  209. package/dist/files/attachment-content.js +0 -117
  210. package/dist/files/attachment-content.js.map +0 -1
  211. package/dist/files/file-mention-query.d.ts +0 -9
  212. package/dist/files/file-mention-query.d.ts.map +0 -1
  213. package/dist/files/file-mention-query.js +0 -23
  214. package/dist/files/file-mention-query.js.map +0 -1
  215. package/dist/files/prompt-display.d.ts +0 -3
  216. package/dist/files/prompt-display.d.ts.map +0 -1
  217. package/dist/files/prompt-display.js +0 -11
  218. package/dist/files/prompt-display.js.map +0 -1
  219. package/dist/files/types.d.ts +0 -6
  220. package/dist/files/types.d.ts.map +0 -1
  221. package/dist/files/types.js +0 -1
  222. package/dist/files/types.js.map +0 -1
  223. package/dist/files/workspace-files.d.ts +0 -3
  224. package/dist/files/workspace-files.d.ts.map +0 -1
  225. package/dist/files/workspace-files.js +0 -50
  226. package/dist/files/workspace-files.js.map +0 -1
  227. package/dist/hooks/agent-event-handlers.d.ts +0 -11
  228. package/dist/hooks/agent-event-handlers.d.ts.map +0 -1
  229. package/dist/hooks/agent-event-handlers.js +0 -137
  230. package/dist/hooks/agent-event-handlers.js.map +0 -1
  231. package/dist/hooks/chat-local-replies.d.ts +0 -9
  232. package/dist/hooks/chat-local-replies.d.ts.map +0 -1
  233. package/dist/hooks/chat-local-replies.js +0 -54
  234. package/dist/hooks/chat-local-replies.js.map +0 -1
  235. package/dist/hooks/turn-updater.d.ts +0 -9
  236. package/dist/hooks/turn-updater.d.ts.map +0 -1
  237. package/dist/hooks/turn-updater.js +0 -103
  238. package/dist/hooks/turn-updater.js.map +0 -1
  239. package/dist/hooks/use-agent-chat.d.ts +0 -29
  240. package/dist/hooks/use-agent-chat.d.ts.map +0 -1
  241. package/dist/hooks/use-agent-chat.js +0 -455
  242. package/dist/hooks/use-agent-chat.js.map +0 -1
  243. package/dist/hooks/use-file-mention-menu.d.ts +0 -22
  244. package/dist/hooks/use-file-mention-menu.d.ts.map +0 -1
  245. package/dist/hooks/use-file-mention-menu.js +0 -137
  246. package/dist/hooks/use-file-mention-menu.js.map +0 -1
  247. package/dist/hooks/use-file-picker.d.ts +0 -21
  248. package/dist/hooks/use-file-picker.d.ts.map +0 -1
  249. package/dist/hooks/use-file-picker.js +0 -145
  250. package/dist/hooks/use-file-picker.js.map +0 -1
  251. package/dist/hooks/use-model-picker.d.ts +0 -23
  252. package/dist/hooks/use-model-picker.d.ts.map +0 -1
  253. package/dist/hooks/use-model-picker.js +0 -151
  254. package/dist/hooks/use-model-picker.js.map +0 -1
  255. package/dist/hooks/use-slash-command-menu.d.ts +0 -19
  256. package/dist/hooks/use-slash-command-menu.d.ts.map +0 -1
  257. package/dist/hooks/use-slash-command-menu.js +0 -101
  258. package/dist/hooks/use-slash-command-menu.js.map +0 -1
  259. package/dist/index.d.ts +0 -2
  260. package/dist/index.d.ts.map +0 -1
  261. package/dist/index.js +0 -39
  262. package/dist/index.js.map +0 -1
  263. package/dist/runtime/clipboard.d.ts +0 -10
  264. package/dist/runtime/clipboard.d.ts.map +0 -1
  265. package/dist/runtime/clipboard.js +0 -64
  266. package/dist/runtime/clipboard.js.map +0 -1
  267. package/dist/runtime/exit.d.ts +0 -7
  268. package/dist/runtime/exit.d.ts.map +0 -1
  269. package/dist/runtime/exit.js +0 -85
  270. package/dist/runtime/exit.js.map +0 -1
  271. package/dist/runtime/terminal-theme.d.ts +0 -25
  272. package/dist/runtime/terminal-theme.d.ts.map +0 -1
  273. package/dist/runtime/terminal-theme.js +0 -148
  274. package/dist/runtime/terminal-theme.js.map +0 -1
  275. package/dist/types/chat.d.ts +0 -29
  276. package/dist/types/chat.d.ts.map +0 -1
  277. package/dist/types/chat.js +0 -1
  278. package/dist/types/chat.js.map +0 -1
  279. package/dist/types/message-content.d.ts +0 -38
  280. package/dist/types/message-content.d.ts.map +0 -1
  281. package/dist/types/message-content.js +0 -1
  282. package/dist/types/message-content.js.map +0 -1
  283. package/dist/ui/open-code-theme.d.ts +0 -58
  284. package/dist/ui/open-code-theme.d.ts.map +0 -1
  285. package/dist/ui/open-code-theme.js +0 -113
  286. package/dist/ui/open-code-theme.js.map +0 -1
  287. package/dist/ui/opencode-markdown.d.ts +0 -7
  288. package/dist/ui/opencode-markdown.d.ts.map +0 -1
  289. package/dist/ui/opencode-markdown.js +0 -169
  290. package/dist/ui/opencode-markdown.js.map +0 -1
  291. package/dist/ui/theme.d.ts +0 -68
  292. package/dist/ui/theme.d.ts.map +0 -1
  293. package/dist/ui/theme.js +0 -80
  294. package/dist/ui/theme.js.map +0 -1
  295. package/dist/utils/time.d.ts +0 -2
  296. package/dist/utils/time.d.ts.map +0 -1
  297. package/dist/utils/time.js +0 -7
  298. package/dist/utils/time.js.map +0 -1
@@ -0,0 +1,154 @@
1
+ import type { KeyEvent, TextareaRenderable } from '@opentui/core';
2
+ import { useCallback, useEffect, useMemo, useState, type RefObject } from 'react';
3
+
4
+ import { filterSlashCommands, type SlashCommandDefinition } from '../commands/slash-commands';
5
+
6
+ type UseSlashCommandMenuParams = {
7
+ value: string;
8
+ onValueChange: (value: string) => void;
9
+ textareaRef: RefObject<TextareaRenderable | null>;
10
+ onCommandSelected?: (command: SlashCommandDefinition) => boolean;
11
+ disabled?: boolean;
12
+ };
13
+
14
+ type UseSlashCommandMenuResult = {
15
+ visible: boolean;
16
+ options: SlashCommandDefinition[];
17
+ selectedIndex: number;
18
+ handleKeyDown: (event: KeyEvent) => boolean;
19
+ };
20
+
21
+ const getSlashQuery = (value: string): string | null => {
22
+ if (!/^\/[^\s]*$/.test(value)) {
23
+ return null;
24
+ }
25
+ return value.slice(1);
26
+ };
27
+
28
+ export const useSlashCommandMenu = ({
29
+ value,
30
+ onValueChange,
31
+ textareaRef,
32
+ onCommandSelected,
33
+ disabled = false,
34
+ }: UseSlashCommandMenuParams): UseSlashCommandMenuResult => {
35
+ const [selectedIndex, setSelectedIndex] = useState(0);
36
+ const [dismissedQuery, setDismissedQuery] = useState<string | null>(null);
37
+
38
+ const query = useMemo(() => getSlashQuery(value), [value]);
39
+
40
+ useEffect(() => {
41
+ if (query !== dismissedQuery) {
42
+ return;
43
+ }
44
+ setDismissedQuery(null);
45
+ }, [dismissedQuery, query]);
46
+
47
+ const options = useMemo(() => {
48
+ if (query === null) {
49
+ return [];
50
+ }
51
+ return filterSlashCommands(query);
52
+ }, [query]);
53
+
54
+ const visible = !disabled && query !== null && query !== dismissedQuery && options.length > 0;
55
+
56
+ useEffect(() => {
57
+ setSelectedIndex(0);
58
+ }, [query]);
59
+
60
+ useEffect(() => {
61
+ if (selectedIndex < options.length) {
62
+ return;
63
+ }
64
+ setSelectedIndex(0);
65
+ }, [options.length, selectedIndex]);
66
+
67
+ const applySelection = useCallback(
68
+ (index: number) => {
69
+ const command = options[index];
70
+ if (!command) {
71
+ return false;
72
+ }
73
+
74
+ if (onCommandSelected?.(command)) {
75
+ return true;
76
+ }
77
+
78
+ const nextValue = `/${command.name} `;
79
+ onValueChange(nextValue);
80
+
81
+ const textarea = textareaRef.current;
82
+ if (textarea) {
83
+ textarea.setText(nextValue);
84
+ textarea.cursorOffset = nextValue.length;
85
+ }
86
+
87
+ return true;
88
+ },
89
+ [onCommandSelected, onValueChange, options, textareaRef]
90
+ );
91
+
92
+ const moveSelection = useCallback(
93
+ (step: number) => {
94
+ if (!visible || options.length === 0) {
95
+ return;
96
+ }
97
+ setSelectedIndex(current => {
98
+ const total = options.length;
99
+ const next = (current + step + total) % total;
100
+ return next;
101
+ });
102
+ },
103
+ [options.length, visible]
104
+ );
105
+
106
+ const handleKeyDown = useCallback(
107
+ (event: KeyEvent): boolean => {
108
+ if (!visible) {
109
+ return false;
110
+ }
111
+
112
+ const name = (event.name ?? '').toLowerCase();
113
+ const ctrlOnly = !!event.ctrl && !event.shift && !event.meta;
114
+ const isUp = name === 'up' || (ctrlOnly && name === 'p');
115
+ const isDown = name === 'down' || (ctrlOnly && name === 'n');
116
+
117
+ if (isUp) {
118
+ moveSelection(-1);
119
+ event.preventDefault();
120
+ return true;
121
+ }
122
+
123
+ if (isDown) {
124
+ moveSelection(1);
125
+ event.preventDefault();
126
+ return true;
127
+ }
128
+
129
+ if (name === 'escape') {
130
+ setDismissedQuery(query);
131
+ event.preventDefault();
132
+ return true;
133
+ }
134
+
135
+ if (name === 'return' || name === 'enter' || name === 'tab') {
136
+ const applied = applySelection(selectedIndex);
137
+ if (applied) {
138
+ event.preventDefault();
139
+ return true;
140
+ }
141
+ }
142
+
143
+ return false;
144
+ },
145
+ [applySelection, moveSelection, query, selectedIndex, visible]
146
+ );
147
+
148
+ return {
149
+ visible,
150
+ options,
151
+ selectedIndex,
152
+ handleKeyDown,
153
+ };
154
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,55 @@
1
+ import { createCliRenderer } from '@opentui/core';
2
+ import { createRoot } from '@opentui/react';
3
+
4
+ import { App } from './App';
5
+ import {
6
+ bindExitGuards,
7
+ hardResetTerminal,
8
+ initExitRuntime,
9
+ registerTerminalBackgroundRestore,
10
+ } from './runtime/exit';
11
+ import {
12
+ probeTerminalColors,
13
+ setTerminalWindowBackground,
14
+ setTerminalWindowForeground,
15
+ } from './runtime/terminal-theme';
16
+ import { applyMarkdownThemeMode } from './ui/opencode-markdown';
17
+ import { applyUiThemeMode, uiTheme } from './ui/theme';
18
+
19
+ bindExitGuards();
20
+ // OpenTUI exposes OPENTUI_FORCE_WCWIDTH for terminals where CJK width handling
21
+ // is more accurate with wcwidth than the default Unicode capability probe.
22
+ process.env.OPENTUI_FORCE_WCWIDTH ??= '1';
23
+ const terminalColors = await probeTerminalColors();
24
+ applyUiThemeMode(terminalColors.mode);
25
+ applyMarkdownThemeMode(terminalColors.mode, process.platform);
26
+
27
+ if (
28
+ terminalColors.rawBackgroundColor &&
29
+ terminalColors.rawBackgroundColor.toLowerCase() !== uiTheme.bg.toLowerCase()
30
+ ) {
31
+ const originalBackground = terminalColors.rawBackgroundColor;
32
+ setTerminalWindowBackground(uiTheme.bg);
33
+ registerTerminalBackgroundRestore(() => {
34
+ setTerminalWindowBackground(originalBackground);
35
+ });
36
+ }
37
+
38
+ if (
39
+ terminalColors.rawForegroundColor &&
40
+ terminalColors.rawForegroundColor.toLowerCase() !== uiTheme.userPromptText.toLowerCase()
41
+ ) {
42
+ const originalForeground = terminalColors.rawForegroundColor;
43
+ setTerminalWindowForeground(uiTheme.userPromptText);
44
+ registerTerminalBackgroundRestore(() => {
45
+ setTerminalWindowForeground(originalForeground);
46
+ });
47
+ }
48
+
49
+ const renderer = await createCliRenderer({
50
+ exitOnCtrlC: false,
51
+ onDestroy: hardResetTerminal,
52
+ backgroundColor: uiTheme.bg,
53
+ });
54
+ initExitRuntime(renderer);
55
+ createRoot(renderer).render(<App />);
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { getClipboardCommandCandidates } from './clipboard';
4
+
5
+ describe('clipboard runtime', () => {
6
+ it('returns pbcopy on macOS', () => {
7
+ expect(getClipboardCommandCandidates('darwin', {} as NodeJS.ProcessEnv)).toEqual([
8
+ { command: 'pbcopy', args: [] },
9
+ ]);
10
+ });
11
+
12
+ it('returns clip on Windows', () => {
13
+ expect(getClipboardCommandCandidates('win32', {} as NodeJS.ProcessEnv)).toEqual([
14
+ { command: 'cmd', args: ['/c', 'clip'] },
15
+ ]);
16
+ });
17
+
18
+ it('prefers Wayland tools when WAYLAND_DISPLAY is present', () => {
19
+ expect(
20
+ getClipboardCommandCandidates('linux', {
21
+ WAYLAND_DISPLAY: 'wayland-0',
22
+ } as NodeJS.ProcessEnv)
23
+ ).toEqual([{ command: 'wl-copy', args: [] }]);
24
+ });
25
+
26
+ it('returns X11 candidates when DISPLAY is present', () => {
27
+ expect(
28
+ getClipboardCommandCandidates('linux', {
29
+ DISPLAY: ':0',
30
+ } as NodeJS.ProcessEnv)
31
+ ).toEqual([
32
+ { command: 'xclip', args: ['-selection', 'clipboard'] },
33
+ { command: 'xsel', args: ['--clipboard', '--input'] },
34
+ { command: 'wl-copy', args: [] },
35
+ ]);
36
+ });
37
+
38
+ it('falls back to wl-copy on Linux without display hints', () => {
39
+ expect(getClipboardCommandCandidates('linux', {} as NodeJS.ProcessEnv)).toEqual([
40
+ { command: 'wl-copy', args: [] },
41
+ ]);
42
+ });
43
+ });
@@ -0,0 +1,89 @@
1
+ import type { CliRenderer } from '@opentui/core';
2
+ import { spawn } from 'node:child_process';
3
+
4
+ type ClipboardCommand = {
5
+ command: string;
6
+ args: string[];
7
+ };
8
+
9
+ type ClipboardRenderer = Pick<CliRenderer, 'copyToClipboardOSC52'>;
10
+
11
+ const runClipboardCommand = (text: string, candidate: ClipboardCommand): Promise<boolean> => {
12
+ return new Promise(resolve => {
13
+ let settled = false;
14
+ const finish = (success: boolean) => {
15
+ if (settled) {
16
+ return;
17
+ }
18
+ settled = true;
19
+ resolve(success);
20
+ };
21
+
22
+ try {
23
+ const child = spawn(candidate.command, candidate.args, {
24
+ stdio: ['pipe', 'ignore', 'ignore'],
25
+ windowsHide: true,
26
+ });
27
+
28
+ child.once('error', () => {
29
+ finish(false);
30
+ });
31
+ child.once('close', code => {
32
+ finish(code === 0);
33
+ });
34
+ child.stdin.on('error', () => {
35
+ finish(false);
36
+ });
37
+ child.stdin.end(text);
38
+ } catch {
39
+ finish(false);
40
+ }
41
+ });
42
+ };
43
+
44
+ export const getClipboardCommandCandidates = (
45
+ platform: NodeJS.Platform = process.platform,
46
+ env: NodeJS.ProcessEnv = process.env
47
+ ): ClipboardCommand[] => {
48
+ if (platform === 'darwin') {
49
+ return [{ command: 'pbcopy', args: [] }];
50
+ }
51
+
52
+ if (platform === 'win32') {
53
+ return [{ command: 'cmd', args: ['/c', 'clip'] }];
54
+ }
55
+
56
+ const candidates: ClipboardCommand[] = [];
57
+
58
+ if (env['WAYLAND_DISPLAY']) {
59
+ candidates.push({ command: 'wl-copy', args: [] });
60
+ }
61
+
62
+ if (env['DISPLAY']) {
63
+ candidates.push({ command: 'xclip', args: ['-selection', 'clipboard'] });
64
+ candidates.push({ command: 'xsel', args: ['--clipboard', '--input'] });
65
+ }
66
+
67
+ if (!env['WAYLAND_DISPLAY']) {
68
+ candidates.push({ command: 'wl-copy', args: [] });
69
+ }
70
+
71
+ return candidates;
72
+ };
73
+
74
+ export const copyTextToClipboard = async (
75
+ text: string,
76
+ renderer: ClipboardRenderer | null = null
77
+ ): Promise<boolean> => {
78
+ if (!text) {
79
+ return false;
80
+ }
81
+
82
+ for (const candidate of getClipboardCommandCandidates()) {
83
+ if (await runClipboardCommand(text, candidate)) {
84
+ return true;
85
+ }
86
+ }
87
+
88
+ return renderer?.copyToClipboardOSC52(text) ?? false;
89
+ };
@@ -0,0 +1,177 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
2
+ import type { CliRenderer } from '@opentui/core';
3
+ import {
4
+ bindExitGuards,
5
+ hardResetTerminal,
6
+ initExitRuntime,
7
+ registerTerminalBackgroundRestore,
8
+ requestExit,
9
+ } from './exit';
10
+
11
+ const originalProcess = global.process;
12
+ const mockProcess = {
13
+ ...originalProcess,
14
+ stdout: {
15
+ ...originalProcess.stdout,
16
+ isTTY: true,
17
+ write: mock(() => {}),
18
+ },
19
+ stdin: {
20
+ ...originalProcess.stdin,
21
+ isTTY: true,
22
+ setRawMode: mock(() => {}),
23
+ },
24
+ on: mock(() => {}),
25
+ once: mock(() => {}),
26
+ exit: mock(() => {}),
27
+ };
28
+
29
+ const originalConsoleError = console.error;
30
+ const mockConsoleError = mock(() => {});
31
+
32
+ describe('exit module', () => {
33
+ beforeEach(() => {
34
+ mockProcess.stdout.write.mockClear();
35
+ mockProcess.stdin.setRawMode.mockClear();
36
+ mockProcess.on.mockClear();
37
+ mockProcess.once.mockClear();
38
+ mockProcess.exit.mockClear();
39
+ mockConsoleError.mockClear();
40
+ mockProcess.stdout.isTTY = true;
41
+ mockProcess.stdin.isTTY = true;
42
+
43
+ global.process = mockProcess as typeof process;
44
+ console.error = mockConsoleError as typeof console.error;
45
+ });
46
+
47
+ afterEach(() => {
48
+ global.process = originalProcess;
49
+ console.error = originalConsoleError;
50
+ });
51
+
52
+ describe('registerTerminalBackgroundRestore', () => {
53
+ it('registers restore function', () => {
54
+ const restoreFn = () => undefined;
55
+ registerTerminalBackgroundRestore(restoreFn);
56
+ expect(() => registerTerminalBackgroundRestore(restoreFn)).not.toThrow();
57
+ });
58
+
59
+ it('allows null restore function', () => {
60
+ expect(() => registerTerminalBackgroundRestore(null)).not.toThrow();
61
+ });
62
+ });
63
+
64
+ describe('hardResetTerminal', () => {
65
+ it('resets terminal when stdout is TTY', () => {
66
+ hardResetTerminal();
67
+
68
+ expect(mockProcess.stdout.write).toHaveBeenCalled();
69
+ expect(mockProcess.stdin.setRawMode).toHaveBeenCalledWith(false);
70
+ });
71
+
72
+ it('does not reset terminal when stdout is not TTY', () => {
73
+ mockProcess.stdout.isTTY = false;
74
+ mockProcess.stdin.isTTY = false;
75
+
76
+ hardResetTerminal();
77
+
78
+ expect(mockProcess.stdout.write).not.toHaveBeenCalled();
79
+ expect(mockProcess.stdin.setRawMode).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it('calls registered restore function', () => {
83
+ const restoreFn = mock(() => {});
84
+ registerTerminalBackgroundRestore(restoreFn);
85
+
86
+ hardResetTerminal();
87
+
88
+ expect(restoreFn).toHaveBeenCalled();
89
+ });
90
+
91
+ it('handles write errors gracefully', () => {
92
+ mockProcess.stdout.write.mockImplementation(() => {
93
+ throw new Error('Write error');
94
+ });
95
+
96
+ expect(() => hardResetTerminal()).not.toThrow();
97
+ });
98
+ });
99
+
100
+ describe('initExitRuntime', () => {
101
+ it('stores renderer reference', () => {
102
+ const mockRenderer = {} as CliRenderer;
103
+ initExitRuntime(mockRenderer);
104
+
105
+ expect(() => initExitRuntime(mockRenderer)).not.toThrow();
106
+ });
107
+ });
108
+
109
+ describe('requestExit', () => {
110
+ it('exits with default code 0', async () => {
111
+ const mockRenderer = {
112
+ useMouse: false,
113
+ setTerminalTitle: mock(() => {}),
114
+ disableKittyKeyboard: mock(() => {}),
115
+ destroy: mock(() => {}),
116
+ } as CliRenderer;
117
+
118
+ initExitRuntime(mockRenderer);
119
+ await requestExit();
120
+
121
+ expect(mockRenderer.destroy).toHaveBeenCalled();
122
+ expect(mockProcess.exit).toHaveBeenCalledWith(0);
123
+ });
124
+
125
+ it('exits with specified code', async () => {
126
+ initExitRuntime(null as unknown as CliRenderer);
127
+ await requestExit(1);
128
+ expect(mockProcess.exit).toHaveBeenCalledWith(1);
129
+ });
130
+
131
+ it('does not exit twice if already cleaned up', async () => {
132
+ initExitRuntime(null as unknown as CliRenderer);
133
+ await requestExit(0);
134
+ expect(mockProcess.exit).toHaveBeenCalledTimes(1);
135
+
136
+ mockProcess.exit.mockClear();
137
+
138
+ await requestExit(0);
139
+ expect(mockProcess.exit).not.toHaveBeenCalled();
140
+ });
141
+
142
+ it('handles missing renderer gracefully', async () => {
143
+ initExitRuntime(null as unknown as CliRenderer);
144
+ await expect(requestExit(0)).resolves.toBeUndefined();
145
+ });
146
+
147
+ it('handles renderer errors gracefully', async () => {
148
+ const mockRenderer = {
149
+ useMouse: false,
150
+ setTerminalTitle: mock(() => {
151
+ throw new Error('Title error');
152
+ }),
153
+ disableKittyKeyboard: mock(() => {
154
+ throw new Error('Keyboard error');
155
+ }),
156
+ destroy: mock(() => {
157
+ throw new Error('Destroy error');
158
+ }),
159
+ } as CliRenderer;
160
+
161
+ initExitRuntime(mockRenderer);
162
+
163
+ await expect(requestExit(0)).resolves.toBeUndefined();
164
+ expect(mockProcess.exit).toHaveBeenCalledWith(0);
165
+ });
166
+ });
167
+
168
+ describe('bindExitGuards', () => {
169
+ it('binds exit handlers', () => {
170
+ bindExitGuards();
171
+
172
+ expect(mockProcess.once).toHaveBeenCalledWith('SIGINT', expect.any(Function));
173
+ expect(mockProcess.once).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
174
+ expect(mockProcess.once).toHaveBeenCalledWith('exit', expect.any(Function));
175
+ });
176
+ });
177
+ });
@@ -0,0 +1,98 @@
1
+ import type { CliRenderer } from '@opentui/core';
2
+ import { disposeAgentRuntime } from '../agent/runtime/runtime';
3
+
4
+ const TERMINAL_RESET_SEQUENCE = [
5
+ '\u001b[0m',
6
+ '\u001b[?25h',
7
+ '\u001b[?1000l',
8
+ '\u001b[?1002l',
9
+ '\u001b[?1003l',
10
+ '\u001b[?1006l',
11
+ '\u001b[?2004l',
12
+ '\u001b[?2026l',
13
+ '\u001b[?2027l',
14
+ '\u001b[?2031l',
15
+ '\u001b[?1049l',
16
+ ].join('');
17
+
18
+ let rendererRef: CliRenderer | null = null;
19
+ let hasCleanedUp = false;
20
+ let terminalBackgroundRestore: (() => void) | null = null;
21
+ let exitPromise: Promise<void> | null = null;
22
+
23
+ export const registerTerminalBackgroundRestore = (restore: (() => void) | null) => {
24
+ terminalBackgroundRestore = restore;
25
+ };
26
+
27
+ export const hardResetTerminal = () => {
28
+ if (!globalThis.process.stdout.isTTY) {
29
+ return;
30
+ }
31
+
32
+ try {
33
+ const restore = terminalBackgroundRestore;
34
+ terminalBackgroundRestore = null;
35
+ restore?.();
36
+
37
+ globalThis.process.stdout.write(TERMINAL_RESET_SEQUENCE);
38
+ if (
39
+ globalThis.process.stdin.isTTY &&
40
+ typeof globalThis.process.stdin.setRawMode === 'function'
41
+ ) {
42
+ globalThis.process.stdin.setRawMode(false);
43
+ }
44
+ } catch {
45
+ // Ignore errors during terminal reset
46
+ }
47
+ };
48
+
49
+ export const initExitRuntime = (renderer: CliRenderer) => {
50
+ hasCleanedUp = false;
51
+ exitPromise = null;
52
+ rendererRef = renderer;
53
+ };
54
+
55
+ export const requestExit = async (exitCode = 0): Promise<void> => {
56
+ if (exitPromise) {
57
+ return exitPromise;
58
+ }
59
+
60
+ exitPromise = (async () => {
61
+ if (hasCleanedUp) {
62
+ return;
63
+ }
64
+ hasCleanedUp = true;
65
+
66
+ try {
67
+ await disposeAgentRuntime();
68
+ } catch {
69
+ // Ignore errors during runtime disposal and continue exiting
70
+ }
71
+
72
+ try {
73
+ if (rendererRef) {
74
+ rendererRef.useMouse = false;
75
+ rendererRef.setTerminalTitle('');
76
+ rendererRef.disableKittyKeyboard();
77
+ rendererRef.destroy();
78
+ }
79
+ } catch {
80
+ // Ignore errors during renderer cleanup
81
+ }
82
+
83
+ hardResetTerminal();
84
+ globalThis.process.exit(exitCode);
85
+ })();
86
+
87
+ return exitPromise;
88
+ };
89
+
90
+ export const bindExitGuards = () => {
91
+ globalThis.process.once('SIGINT', () => {
92
+ void requestExit(0);
93
+ });
94
+ globalThis.process.once('SIGTERM', () => {
95
+ void requestExit(0);
96
+ });
97
+ globalThis.process.once('exit', hardResetTerminal);
98
+ };
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ ensureSupportedRuntime,
5
+ getUnsupportedRuntimeMessage,
6
+ isBunRuntime,
7
+ } from './runtime-support';
8
+
9
+ describe('runtime support', () => {
10
+ it('detects bun via process.versions.bun', () => {
11
+ const originalVersions = process.versions;
12
+ vi.stubGlobal('process', {
13
+ ...process,
14
+ versions: {
15
+ ...originalVersions,
16
+ bun: '1.2.0',
17
+ },
18
+ });
19
+
20
+ expect(isBunRuntime()).toBe(true);
21
+
22
+ vi.unstubAllGlobals();
23
+ });
24
+
25
+ it('returns false and prints a helpful message when bun is unavailable', () => {
26
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
27
+
28
+ expect(ensureSupportedRuntime()).toBe(false);
29
+ expect(errorSpy).toHaveBeenCalledWith(getUnsupportedRuntimeMessage());
30
+ });
31
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { extractOsc11Color, modeFromColor, parseTerminalColor } from './terminal-theme';
4
+
5
+ describe('parseTerminalColor', () => {
6
+ it('parses rgb:RRRR/GGGG/BBBB format', () => {
7
+ expect(parseTerminalColor('rgb:ffff/8080/0000')).toEqual({
8
+ r: 255,
9
+ g: 128,
10
+ b: 0,
11
+ });
12
+ });
13
+
14
+ it('parses #RRGGBB format', () => {
15
+ expect(parseTerminalColor('#1a2b3c')).toEqual({
16
+ r: 26,
17
+ g: 43,
18
+ b: 60,
19
+ });
20
+ });
21
+
22
+ it('parses rgb(R,G,B) format', () => {
23
+ expect(parseTerminalColor('rgb(12, 34, 56)')).toEqual({
24
+ r: 12,
25
+ g: 34,
26
+ b: 56,
27
+ });
28
+ });
29
+
30
+ it('returns null for unsupported values', () => {
31
+ expect(parseTerminalColor('rgba(1,2,3,0.5)')).toBeNull();
32
+ expect(parseTerminalColor('#12345')).toBeNull();
33
+ });
34
+ });
35
+
36
+ describe('extractOsc11Color', () => {
37
+ it('extracts OSC 11 color payload', () => {
38
+ const input = '\u001b]11;rgb:ffff/eeee/dddd\u0007';
39
+ expect(extractOsc11Color(input)).toBe('rgb:ffff/eeee/dddd');
40
+ });
41
+
42
+ it('returns null when payload is absent', () => {
43
+ expect(extractOsc11Color('plain text')).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe('modeFromColor', () => {
48
+ it('detects dark backgrounds', () => {
49
+ expect(modeFromColor({ r: 5, g: 6, b: 8 })).toBe('dark');
50
+ });
51
+
52
+ it('detects light backgrounds', () => {
53
+ expect(modeFromColor({ r: 245, g: 246, b: 248 })).toBe('light');
54
+ });
55
+ });