@renxqoo/renx-code 0.0.3 → 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 (306) hide show
  1. package/README.md +58 -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 -12
  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 -48
  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 -6
  262. package/dist/index.js.map +0 -1
  263. package/dist/run-cli-app.d.ts +0 -2
  264. package/dist/run-cli-app.d.ts.map +0 -1
  265. package/dist/run-cli-app.js +0 -41
  266. package/dist/run-cli-app.js.map +0 -1
  267. package/dist/runtime/clipboard.d.ts +0 -10
  268. package/dist/runtime/clipboard.d.ts.map +0 -1
  269. package/dist/runtime/clipboard.js +0 -64
  270. package/dist/runtime/clipboard.js.map +0 -1
  271. package/dist/runtime/exit.d.ts +0 -7
  272. package/dist/runtime/exit.d.ts.map +0 -1
  273. package/dist/runtime/exit.js +0 -85
  274. package/dist/runtime/exit.js.map +0 -1
  275. package/dist/runtime/runtime-support.d.ts +0 -4
  276. package/dist/runtime/runtime-support.d.ts.map +0 -1
  277. package/dist/runtime/runtime-support.js +0 -19
  278. package/dist/runtime/runtime-support.js.map +0 -1
  279. package/dist/runtime/terminal-theme.d.ts +0 -25
  280. package/dist/runtime/terminal-theme.d.ts.map +0 -1
  281. package/dist/runtime/terminal-theme.js +0 -148
  282. package/dist/runtime/terminal-theme.js.map +0 -1
  283. package/dist/types/chat.d.ts +0 -29
  284. package/dist/types/chat.d.ts.map +0 -1
  285. package/dist/types/chat.js +0 -1
  286. package/dist/types/chat.js.map +0 -1
  287. package/dist/types/message-content.d.ts +0 -38
  288. package/dist/types/message-content.d.ts.map +0 -1
  289. package/dist/types/message-content.js +0 -1
  290. package/dist/types/message-content.js.map +0 -1
  291. package/dist/ui/open-code-theme.d.ts +0 -58
  292. package/dist/ui/open-code-theme.d.ts.map +0 -1
  293. package/dist/ui/open-code-theme.js +0 -113
  294. package/dist/ui/open-code-theme.js.map +0 -1
  295. package/dist/ui/opencode-markdown.d.ts +0 -7
  296. package/dist/ui/opencode-markdown.d.ts.map +0 -1
  297. package/dist/ui/opencode-markdown.js +0 -169
  298. package/dist/ui/opencode-markdown.js.map +0 -1
  299. package/dist/ui/theme.d.ts +0 -68
  300. package/dist/ui/theme.d.ts.map +0 -1
  301. package/dist/ui/theme.js +0 -80
  302. package/dist/ui/theme.js.map +0 -1
  303. package/dist/utils/time.d.ts +0 -2
  304. package/dist/utils/time.d.ts.map +0 -1
  305. package/dist/utils/time.js +0 -7
  306. package/dist/utils/time.js.map +0 -1
@@ -0,0 +1,60 @@
1
+ import type { AgentToolUseEvent } from './types';
2
+
3
+ const readToolCallId = (event: AgentToolUseEvent): string | undefined => {
4
+ const maybeId = (event as { id?: unknown }).id;
5
+ return typeof maybeId === 'string' && maybeId.length > 0 ? maybeId : undefined;
6
+ };
7
+
8
+ export class ToolCallBuffer {
9
+ private readonly plannedOrder: string[] = [];
10
+ private readonly plannedIds = new Set<string>();
11
+ private readonly toolCallsById = new Map<string, AgentToolUseEvent>();
12
+ private readonly emittedIds = new Set<string>();
13
+
14
+ register(
15
+ toolCall: AgentToolUseEvent,
16
+ emit: (event: AgentToolUseEvent) => void,
17
+ executing = false
18
+ ) {
19
+ const toolCallId = readToolCallId(toolCall);
20
+ if (!toolCallId) {
21
+ emit(toolCall);
22
+ return;
23
+ }
24
+
25
+ this.toolCallsById.set(toolCallId, toolCall);
26
+ if (!this.plannedIds.has(toolCallId)) {
27
+ this.plannedIds.add(toolCallId);
28
+ this.plannedOrder.push(toolCallId);
29
+ }
30
+
31
+ if (executing) {
32
+ this.emit(toolCallId, emit);
33
+ }
34
+ }
35
+
36
+ flush(emit: (event: AgentToolUseEvent) => void) {
37
+ for (const toolCallId of this.plannedOrder) {
38
+ this.emit(toolCallId, emit);
39
+ }
40
+ }
41
+
42
+ ensureEmitted(toolCallId: string | undefined, emit: (event: AgentToolUseEvent) => void) {
43
+ if (!toolCallId) {
44
+ return;
45
+ }
46
+ this.emit(toolCallId, emit);
47
+ }
48
+
49
+ private emit(toolCallId: string, emit: (event: AgentToolUseEvent) => void) {
50
+ if (this.emittedIds.has(toolCallId)) {
51
+ return;
52
+ }
53
+ const toolCall = this.toolCallsById.get(toolCallId);
54
+ if (!toolCall) {
55
+ return;
56
+ }
57
+ this.emittedIds.add(toolCallId);
58
+ emit(toolCall);
59
+ }
60
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import type { AgentEventHandlers, AgentToolConfirmEvent } from './types';
4
+
5
+ const TOOL_CONFIRM_EVENT: AgentToolConfirmEvent = {
6
+ toolCallId: 'call_1',
7
+ toolName: 'glob',
8
+ args: {
9
+ pattern: '**/*sandbox*',
10
+ path: '/tmp/project',
11
+ },
12
+ rawArgs: {
13
+ pattern: '**/*sandbox*',
14
+ path: '/tmp/project',
15
+ },
16
+ reason: 'SEARCH_PATH_NOT_ALLOWED: /tmp/project is outside allowed directories: /workspace',
17
+ metadata: {
18
+ requestedPath: '/tmp/project',
19
+ allowedDirectories: ['/workspace'],
20
+ },
21
+ };
22
+
23
+ describe('resolveToolConfirmDecision', () => {
24
+ it('asks the UI callback when registered', async () => {
25
+ const { resolveToolConfirmDecision } =
26
+ await vi.importActual<typeof import('./tool-confirmation')>('./tool-confirmation');
27
+ const calls: AgentToolConfirmEvent[] = [];
28
+ const onToolConfirmRequest: NonNullable<
29
+ AgentEventHandlers['onToolConfirmRequest']
30
+ > = async event => {
31
+ calls.push(event);
32
+ return {
33
+ approved: false,
34
+ message: 'Denied by user',
35
+ };
36
+ };
37
+
38
+ const decision = await resolveToolConfirmDecision(TOOL_CONFIRM_EVENT, { onToolConfirmRequest });
39
+
40
+ expect(decision).toEqual({
41
+ approved: false,
42
+ message: 'Denied by user',
43
+ });
44
+ expect(calls).toEqual([TOOL_CONFIRM_EVENT]);
45
+ });
46
+
47
+ it('falls back to approve when no UI callback is registered', async () => {
48
+ const { resolveToolConfirmDecision } =
49
+ await vi.importActual<typeof import('./tool-confirmation')>('./tool-confirmation');
50
+ const decision = await resolveToolConfirmDecision(TOOL_CONFIRM_EVENT, {});
51
+
52
+ expect(decision).toEqual({
53
+ approved: true,
54
+ });
55
+ });
56
+ });
@@ -0,0 +1,15 @@
1
+ import type { AgentEventHandlers, AgentToolConfirmDecision, AgentToolConfirmEvent } from './types';
2
+
3
+ const DEFAULT_FALLBACK_DECISION: AgentToolConfirmDecision = { approved: true };
4
+
5
+ export const resolveToolConfirmDecision = async (
6
+ event: AgentToolConfirmEvent,
7
+ handlers: AgentEventHandlers
8
+ ): Promise<AgentToolConfirmDecision> => {
9
+ if (!handlers.onToolConfirmRequest) {
10
+ return DEFAULT_FALLBACK_DECISION;
11
+ }
12
+
13
+ const decision = await handlers.onToolConfirmRequest(event);
14
+ return decision ?? { approved: false, message: 'Tool confirmation was not resolved.' };
15
+ };
@@ -0,0 +1,99 @@
1
+ export type AgentTextDeltaEvent = {
2
+ text: string;
3
+ isReasoning?: boolean;
4
+ };
5
+
6
+ export type AgentToolStreamEvent = {
7
+ toolCallId: string;
8
+ toolName: string;
9
+ type: string;
10
+ sequence: number;
11
+ timestamp: number;
12
+ content?: string;
13
+ data?: unknown;
14
+ };
15
+
16
+ export type AgentToolConfirmEvent = {
17
+ toolCallId: string;
18
+ toolName: string;
19
+ args: Record<string, unknown>;
20
+ rawArgs: Record<string, unknown>;
21
+ reason?: string;
22
+ metadata?: Record<string, unknown>;
23
+ };
24
+
25
+ export type AgentToolConfirmDecision = {
26
+ approved: boolean;
27
+ message?: string;
28
+ };
29
+
30
+ export type AgentStepEvent = {
31
+ stepIndex: number;
32
+ finishReason?: string;
33
+ toolCallsCount: number;
34
+ };
35
+
36
+ export type AgentToolUseEvent = {
37
+ [key: string]: unknown;
38
+ };
39
+
40
+ export type AgentToolResultEvent = {
41
+ toolCall: unknown;
42
+ result: unknown;
43
+ };
44
+
45
+ export type AgentLoopEvent = {
46
+ loopIndex: number;
47
+ steps: number;
48
+ };
49
+
50
+ export type AgentStopEvent = {
51
+ reason: string;
52
+ message?: string;
53
+ };
54
+
55
+ export type AgentUsageEvent = {
56
+ promptTokens: number;
57
+ completionTokens: number;
58
+ totalTokens: number;
59
+ cumulativePromptTokens?: number;
60
+ cumulativeCompletionTokens?: number;
61
+ cumulativeTotalTokens?: number;
62
+ contextTokens?: number;
63
+ contextLimit?: number;
64
+ contextUsagePercent?: number;
65
+ };
66
+
67
+ export type AgentContextUsageEvent = {
68
+ stepIndex: number;
69
+ messageCount: number;
70
+ contextTokens: number;
71
+ contextLimit: number;
72
+ contextUsagePercent: number;
73
+ };
74
+
75
+ export type AgentEventHandlers = {
76
+ onTextDelta?: (event: AgentTextDeltaEvent) => void;
77
+ onTextComplete?: (text: string) => void;
78
+ onToolStream?: (event: AgentToolStreamEvent) => void;
79
+ onToolConfirm?: (event: AgentToolConfirmEvent) => void;
80
+ onToolConfirmRequest?: (
81
+ event: AgentToolConfirmEvent
82
+ ) => AgentToolConfirmDecision | Promise<AgentToolConfirmDecision>;
83
+ onToolUse?: (event: AgentToolUseEvent) => void;
84
+ onToolResult?: (event: AgentToolResultEvent) => void;
85
+ onStep?: (event: AgentStepEvent) => void;
86
+ onLoop?: (event: AgentLoopEvent) => void;
87
+ onStop?: (event: AgentStopEvent) => void;
88
+ onContextUsage?: (event: AgentContextUsageEvent) => void;
89
+ onUsage?: (event: AgentUsageEvent) => void;
90
+ };
91
+
92
+ export type AgentRunResult = {
93
+ text: string;
94
+ completionReason: string;
95
+ completionMessage?: string;
96
+ durationSeconds: number;
97
+ modelLabel: string;
98
+ usage?: AgentUsageEvent;
99
+ };
@@ -0,0 +1,216 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { SLASH_COMMANDS, filterSlashCommands, resolveSlashCommand } from './slash-commands';
4
+
5
+ describe('slash-commands', () => {
6
+ describe('resolveSlashCommand', () => {
7
+ it('should return null for empty input', () => {
8
+ expect(resolveSlashCommand('')).toBe(null);
9
+ expect(resolveSlashCommand(' ')).toBe(null);
10
+ expect(resolveSlashCommand('\t\n')).toBe(null);
11
+ });
12
+
13
+ it('should return null for non-slash commands', () => {
14
+ expect(resolveSlashCommand('hello world')).toBe(null);
15
+ // 'help me' returns help command because the first token 'help' matches
16
+ // but the caller checks text.startsWith('/') before using runCommand
17
+ expect(resolveSlashCommand('help me')?.name).toBe('help');
18
+ expect(resolveSlashCommand('/invalid')).toBe(null);
19
+ });
20
+
21
+ it('should resolve commands by name', () => {
22
+ const helpCommand = resolveSlashCommand('/help');
23
+ expect(helpCommand).not.toBe(null);
24
+ expect(helpCommand?.name).toBe('help');
25
+ expect(helpCommand?.action).toBe('help');
26
+
27
+ const clearCommand = resolveSlashCommand('/clear');
28
+ expect(clearCommand?.name).toBe('clear');
29
+ expect(clearCommand?.action).toBe('clear');
30
+
31
+ const exitCommand = resolveSlashCommand('/exit');
32
+ expect(exitCommand?.name).toBe('exit');
33
+ expect(exitCommand?.action).toBe('exit');
34
+
35
+ const modelsCommand = resolveSlashCommand('/models');
36
+ expect(modelsCommand?.name).toBe('models');
37
+ expect(modelsCommand?.action).toBe('models');
38
+
39
+ const filesCommand = resolveSlashCommand('/files');
40
+ expect(filesCommand?.name).toBe('files');
41
+ expect(filesCommand?.action).toBe('files');
42
+ });
43
+
44
+ it('should resolve commands with aliases', () => {
45
+ // Test aliases for clear
46
+ const clearAlias1 = resolveSlashCommand('/new');
47
+ expect(clearAlias1?.name).toBe('clear');
48
+ expect(clearAlias1?.action).toBe('clear');
49
+
50
+ // Test aliases for exit
51
+ const exitAlias1 = resolveSlashCommand('/quit');
52
+ expect(exitAlias1?.name).toBe('exit');
53
+ expect(exitAlias1?.action).toBe('exit');
54
+
55
+ const exitAlias2 = resolveSlashCommand('/q');
56
+ expect(exitAlias2?.name).toBe('exit');
57
+ expect(exitAlias2?.action).toBe('exit');
58
+
59
+ // Test aliases for help
60
+ const helpAlias1 = resolveSlashCommand('/commands');
61
+ expect(helpAlias1?.name).toBe('help');
62
+ expect(helpAlias1?.action).toBe('help');
63
+
64
+ // Test aliases for models
65
+ const modelsAlias1 = resolveSlashCommand('/model');
66
+ expect(modelsAlias1?.name).toBe('models');
67
+ expect(modelsAlias1?.action).toBe('models');
68
+
69
+ const filesAlias1 = resolveSlashCommand('/file');
70
+ expect(filesAlias1?.name).toBe('files');
71
+ expect(filesAlias1?.action).toBe('files');
72
+ });
73
+
74
+ it('should resolve commands with extra text after command', () => {
75
+ const helpCommand = resolveSlashCommand('/help please');
76
+ expect(helpCommand?.name).toBe('help');
77
+
78
+ const clearCommand = resolveSlashCommand('/clear now');
79
+ expect(clearCommand?.name).toBe('clear');
80
+
81
+ const modelsCommand = resolveSlashCommand('/models with space');
82
+ expect(modelsCommand?.name).toBe('models');
83
+
84
+ const filesCommand = resolveSlashCommand('/files pick');
85
+ expect(filesCommand?.name).toBe('files');
86
+ });
87
+
88
+ it('should be case insensitive', () => {
89
+ expect(resolveSlashCommand('/HELP')?.name).toBe('help');
90
+ expect(resolveSlashCommand('/Help')?.name).toBe('help');
91
+ expect(resolveSlashCommand('/hElP')?.name).toBe('help');
92
+
93
+ expect(resolveSlashCommand('/CLEAR')?.name).toBe('clear');
94
+ expect(resolveSlashCommand('/Clear')?.name).toBe('clear');
95
+
96
+ expect(resolveSlashCommand('/MODELS')?.name).toBe('models');
97
+ expect(resolveSlashCommand('/Models')?.name).toBe('models');
98
+
99
+ expect(resolveSlashCommand('/FILES')?.name).toBe('files');
100
+ expect(resolveSlashCommand('/File')?.name).toBe('files');
101
+ });
102
+
103
+ it('should handle commands with leading/trailing spaces', () => {
104
+ expect(resolveSlashCommand(' /help ')?.name).toBe('help');
105
+ expect(resolveSlashCommand('\t/clear\n')?.name).toBe('clear');
106
+ expect(resolveSlashCommand(' /models please')?.name).toBe('models');
107
+ expect(resolveSlashCommand(' /files now')?.name).toBe('files');
108
+ });
109
+
110
+ it('should return unsupported commands', () => {
111
+ const exportCommand = resolveSlashCommand('/export');
112
+ expect(exportCommand?.name).toBe('export');
113
+ expect(exportCommand?.action).toBe('unsupported');
114
+
115
+ const forkCommand = resolveSlashCommand('/fork');
116
+ expect(forkCommand?.name).toBe('fork');
117
+ expect(forkCommand?.action).toBe('unsupported');
118
+ });
119
+ });
120
+
121
+ describe('filterSlashCommands', () => {
122
+ it('should return all commands for empty query', () => {
123
+ const result = filterSlashCommands('');
124
+ expect(result).toEqual(SLASH_COMMANDS);
125
+
126
+ const result2 = filterSlashCommands(' ');
127
+ expect(result2).toEqual(SLASH_COMMANDS);
128
+ });
129
+
130
+ it('should filter commands by name prefix', () => {
131
+ const result = filterSlashCommands('h');
132
+ expect(result.length).toBeGreaterThan(0);
133
+ expect(
134
+ result.every(
135
+ cmd => cmd.name.includes('h') || cmd.aliases?.some(alias => alias.includes('h'))
136
+ )
137
+ ).toBe(true);
138
+
139
+ const result2 = filterSlashCommands('he');
140
+ const helpCommands = result2.filter(
141
+ cmd => cmd.name.startsWith('he') || cmd.aliases?.some(alias => alias.startsWith('he'))
142
+ );
143
+ expect(helpCommands.length).toBeGreaterThan(0);
144
+ });
145
+
146
+ it('should filter commands by name substring', () => {
147
+ const result = filterSlashCommands('elp'); // part of "help"
148
+ expect(result.length).toBeGreaterThan(0);
149
+ expect(result.some(cmd => cmd.name === 'help')).toBe(true);
150
+ });
151
+
152
+ it('should filter commands by alias', () => {
153
+ const result = filterSlashCommands('q'); // alias for exit
154
+ expect(result.some(cmd => cmd.name === 'exit')).toBe(true);
155
+
156
+ const result2 = filterSlashCommands('commands'); // alias for help
157
+ expect(result2.some(cmd => cmd.name === 'help')).toBe(true);
158
+ });
159
+
160
+ it('should be case insensitive', () => {
161
+ const result1 = filterSlashCommands('HELP');
162
+ const result2 = filterSlashCommands('help');
163
+ expect(result1).toEqual(result2);
164
+
165
+ const result3 = filterSlashCommands('CLEAR');
166
+ const result4 = filterSlashCommands('clear');
167
+ expect(result3).toEqual(result4);
168
+ });
169
+
170
+ it('should handle queries with spaces', () => {
171
+ const result = filterSlashCommands(' help ');
172
+ expect(result.some(cmd => cmd.name === 'help')).toBe(true);
173
+ });
174
+
175
+ it('should return empty array for non-matching query', () => {
176
+ const result = filterSlashCommands('xyz123nonexistent');
177
+ expect(result).toEqual([]);
178
+ });
179
+ });
180
+
181
+ describe('SLASH_COMMANDS', () => {
182
+ it('should have required fields for all commands', () => {
183
+ SLASH_COMMANDS.forEach(command => {
184
+ expect(command.name).toBeDefined();
185
+ expect(command.description).toBeDefined();
186
+ expect(command.action).toBeDefined();
187
+ expect(['help', 'clear', 'exit', 'models', 'files', 'unsupported']).toContain(
188
+ command.action
189
+ );
190
+ });
191
+ });
192
+
193
+ it('should have unique names', () => {
194
+ const names = SLASH_COMMANDS.map(cmd => cmd.name);
195
+ const uniqueNames = new Set(names);
196
+ expect(names.length).toBe(uniqueNames.size);
197
+ });
198
+
199
+ it('should have proper aliases', () => {
200
+ const clearCommand = SLASH_COMMANDS.find(cmd => cmd.name === 'clear');
201
+ expect(clearCommand?.aliases).toEqual(['new']);
202
+
203
+ const exitCommand = SLASH_COMMANDS.find(cmd => cmd.name === 'exit');
204
+ expect(exitCommand?.aliases).toEqual(['quit', 'q']);
205
+
206
+ const helpCommand = SLASH_COMMANDS.find(cmd => cmd.name === 'help');
207
+ expect(helpCommand?.aliases).toEqual(['commands']);
208
+
209
+ const modelsCommand = SLASH_COMMANDS.find(cmd => cmd.name === 'models');
210
+ expect(modelsCommand?.aliases).toEqual(['model']);
211
+
212
+ const filesCommand = SLASH_COMMANDS.find(cmd => cmd.name === 'files');
213
+ expect(filesCommand?.aliases).toEqual(['file']);
214
+ });
215
+ });
216
+ });
@@ -0,0 +1,64 @@
1
+ export type SlashCommandAction = 'help' | 'clear' | 'exit' | 'models' | 'files' | 'unsupported';
2
+
3
+ export type SlashCommandDefinition = {
4
+ name: string;
5
+ description: string;
6
+ action: SlashCommandAction;
7
+ aliases?: string[];
8
+ };
9
+
10
+ export const SLASH_COMMANDS: SlashCommandDefinition[] = [
11
+ { name: 'help', description: 'Help', action: 'help', aliases: ['commands'] },
12
+ { name: 'clear', description: 'Clear conversation', action: 'clear', aliases: ['new'] },
13
+ { name: 'exit', description: 'Exit app', action: 'exit', aliases: ['quit', 'q'] },
14
+ { name: 'export', description: 'Export session transcript', action: 'unsupported' },
15
+ { name: 'fork', description: 'Fork from message', action: 'unsupported' },
16
+ { name: 'init', description: 'create/update AGENTS.md', action: 'unsupported' },
17
+ { name: 'mcps', description: 'Toggle MCPs', action: 'unsupported', aliases: ['mcp'] },
18
+ { name: 'models', description: 'Switch model', action: 'models', aliases: ['model'] },
19
+ { name: 'files', description: 'Attach workspace files', action: 'files', aliases: ['file'] },
20
+ { name: 'rename', description: 'Rename session', action: 'unsupported' },
21
+ { name: 'review', description: 'Review changes', action: 'unsupported' },
22
+ { name: 'sessions', description: 'Switch session', action: 'unsupported', aliases: ['session'] },
23
+ ];
24
+
25
+ const normalize = (value: string) => value.trim().toLowerCase();
26
+
27
+ const getCommandToken = (value: string): string => {
28
+ const normalized = normalize(value);
29
+ const token = normalized.split(/\s+/, 1)[0] ?? '';
30
+ return token.startsWith('/') ? token.slice(1) : token;
31
+ };
32
+
33
+ export const resolveSlashCommand = (value: string): SlashCommandDefinition | null => {
34
+ const token = getCommandToken(value);
35
+ if (!token) {
36
+ return null;
37
+ }
38
+
39
+ return (
40
+ SLASH_COMMANDS.find(command => {
41
+ if (command.name === token) {
42
+ return true;
43
+ }
44
+ return command.aliases?.includes(token) ?? false;
45
+ }) ?? null
46
+ );
47
+ };
48
+
49
+ export const filterSlashCommands = (query: string): SlashCommandDefinition[] => {
50
+ const normalizedQuery = normalize(query);
51
+ if (!normalizedQuery) {
52
+ return SLASH_COMMANDS;
53
+ }
54
+
55
+ return SLASH_COMMANDS.filter(command => {
56
+ if (command.name.startsWith(normalizedQuery)) {
57
+ return true;
58
+ }
59
+ if (command.name.includes(normalizedQuery)) {
60
+ return true;
61
+ }
62
+ return command.aliases?.some(alias => alias.includes(normalizedQuery)) ?? false;
63
+ });
64
+ };
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { AssistantReply as AssistantReplyType } from '../../types/chat';
4
+ import { buildUsageItems, getCompletionErrorMessage } from './assistant-reply';
5
+
6
+ const createReply = (overrides: Partial<AssistantReplyType> = {}): AssistantReplyType => ({
7
+ agentLabel: '',
8
+ modelLabel: 'glm-5',
9
+ durationSeconds: 0.8,
10
+ segments: [],
11
+ status: 'done',
12
+ ...overrides,
13
+ });
14
+
15
+ describe('assistant-reply helpers', () => {
16
+ it('extracts completion error messages for error replies', () => {
17
+ const reply = createReply({
18
+ status: 'error',
19
+ completionReason: 'error',
20
+ completionMessage: 'Server returned 500: upstream provider timeout',
21
+ });
22
+
23
+ expect(getCompletionErrorMessage(reply)).toBe('Server returned 500: upstream provider timeout');
24
+ });
25
+
26
+ it('ignores completion messages for non-error replies', () => {
27
+ const reply = createReply({
28
+ status: 'done',
29
+ completionReason: 'stop',
30
+ completionMessage: 'Should not be shown',
31
+ });
32
+
33
+ expect(getCompletionErrorMessage(reply)).toBeUndefined();
34
+ });
35
+
36
+ it('keeps usage items compact and directional', () => {
37
+ const reply = createReply({
38
+ usagePromptTokens: 1250,
39
+ usageCompletionTokens: 2400,
40
+ });
41
+
42
+ expect(buildUsageItems(reply)).toEqual([
43
+ { icon: '↑', value: '1.25k' },
44
+ { icon: '↓', value: '2.40k' },
45
+ ]);
46
+ });
47
+ });
@@ -0,0 +1,136 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ import type { AssistantReply as AssistantReplyType } from '../../types/chat';
4
+ import { uiTheme } from '../../ui/theme';
5
+ import { AssistantSegment } from './assistant-segment';
6
+ import { AssistantToolGroup } from './assistant-tool-group';
7
+ import { buildReplyRenderItems } from './segment-groups';
8
+
9
+ type AssistantReplyProps = {
10
+ reply: AssistantReplyType;
11
+ };
12
+
13
+ export type AssistantReplyUsageItem = {
14
+ icon: '↓' | '↑';
15
+ value: string;
16
+ };
17
+
18
+ const renderStatus = (status: AssistantReplyType['status']) => {
19
+ if (status === 'streaming') {
20
+ return 'streaming';
21
+ }
22
+ if (status === 'error') {
23
+ return 'error';
24
+ }
25
+ return undefined;
26
+ };
27
+
28
+ const formatDurationSeconds = (reply: AssistantReplyType, nowMs: number): string => {
29
+ if (reply.status !== 'streaming') {
30
+ return reply.durationSeconds.toFixed(1);
31
+ }
32
+ if (typeof reply.startedAtMs !== 'number') {
33
+ return reply.durationSeconds.toFixed(1);
34
+ }
35
+ const elapsedSeconds = Math.max(0, (nowMs - reply.startedAtMs) / 1000);
36
+ return Math.max(reply.durationSeconds, elapsedSeconds).toFixed(1);
37
+ };
38
+
39
+ const formatTokenCount = (tokens: number): string => {
40
+ if (tokens >= 1_000_000) {
41
+ return `${(tokens / 1_000_000).toFixed(2)}M`;
42
+ }
43
+ return `${(tokens / 1_000).toFixed(2)}k`;
44
+ };
45
+
46
+ const normalizeUsageTokens = (tokens: number | undefined): string | undefined => {
47
+ if (typeof tokens !== 'number' || !Number.isFinite(tokens)) {
48
+ return undefined;
49
+ }
50
+ return formatTokenCount(Math.max(0, Math.round(tokens)));
51
+ };
52
+
53
+ export const buildUsageItems = (
54
+ reply: Pick<AssistantReplyType, 'usagePromptTokens' | 'usageCompletionTokens'>
55
+ ): AssistantReplyUsageItem[] => {
56
+ const items: AssistantReplyUsageItem[] = [];
57
+ const promptTokens = normalizeUsageTokens(reply.usagePromptTokens);
58
+ const completionTokens = normalizeUsageTokens(reply.usageCompletionTokens);
59
+
60
+ if (promptTokens) {
61
+ items.push({ icon: '↑', value: promptTokens });
62
+ }
63
+ if (completionTokens) {
64
+ items.push({ icon: '↓', value: completionTokens });
65
+ }
66
+
67
+ return items;
68
+ };
69
+
70
+ export const getCompletionErrorMessage = (reply: AssistantReplyType): string | undefined => {
71
+ if (reply.status !== 'error' && reply.completionReason !== 'error') {
72
+ return undefined;
73
+ }
74
+
75
+ const message = reply.completionMessage?.trim();
76
+ return message ? message : undefined;
77
+ };
78
+
79
+ export const AssistantReply = ({ reply }: AssistantReplyProps) => {
80
+ const status = renderStatus(reply.status);
81
+ const [nowMs, setNowMs] = useState(() => Date.now());
82
+ const items = buildReplyRenderItems(reply.segments);
83
+ const isStreaming = reply.status === 'streaming';
84
+
85
+ useEffect(() => {
86
+ if (reply.status !== 'streaming') {
87
+ return;
88
+ }
89
+ const timer = setInterval(() => {
90
+ setNowMs(Date.now());
91
+ }, 100);
92
+ return () => {
93
+ clearInterval(timer);
94
+ };
95
+ }, [reply.status]);
96
+
97
+ const durationText = formatDurationSeconds(reply, nowMs);
98
+ const usageItems = buildUsageItems(reply);
99
+ const completionErrorMessage = getCompletionErrorMessage(reply);
100
+
101
+ return (
102
+ <box flexDirection="column" gap={1}>
103
+ {items.map((item, index) =>
104
+ item.type === 'tool' ? (
105
+ <AssistantToolGroup
106
+ key={`tool-group:${item.group.toolCallId}:${index}`}
107
+ group={item.group}
108
+ />
109
+ ) : (
110
+ <AssistantSegment key={item.segment.id} segment={item.segment} streaming={isStreaming} />
111
+ )
112
+ )}
113
+ {completionErrorMessage ? (
114
+ <box backgroundColor={uiTheme.surface} paddingX={2} paddingY={1}>
115
+ <text fg="#c2410c" attributes={uiTheme.typography.body} wrapMode="word">
116
+ {completionErrorMessage}
117
+ </text>
118
+ </box>
119
+ ) : null}
120
+ <box flexDirection="row" gap={1} paddingLeft={3}>
121
+ <text fg={uiTheme.muted} attributes={uiTheme.typography.muted}>
122
+ <span fg={uiTheme.accent}>▣</span> assistant
123
+ <span fg={uiTheme.muted}> · {reply.modelLabel}</span>
124
+ <span fg={uiTheme.muted}> · {durationText}s</span>
125
+ {usageItems.map(item => (
126
+ <span key={`${item.icon}:${item.value}`} fg={uiTheme.muted}>
127
+ {' · '}
128
+ {item.icon} {item.value}
129
+ </span>
130
+ ))}
131
+ {status ? <span fg={uiTheme.muted}> · {status}</span> : null}
132
+ </text>
133
+ </box>
134
+ </box>
135
+ );
136
+ };