@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.
- package/README.md +58 -223
- package/bin/renx.cjs +34 -0
- package/package.json +27 -83
- package/src/App.tsx +297 -0
- package/src/agent/runtime/event-format.ts +258 -0
- package/src/agent/runtime/model-types.ts +13 -0
- package/src/agent/runtime/runtime.context-usage.test.ts +193 -0
- package/src/agent/runtime/runtime.error-handling.test.ts +236 -0
- package/src/agent/runtime/runtime.simple.test.ts +16 -0
- package/src/agent/runtime/runtime.test.ts +293 -0
- package/src/agent/runtime/runtime.ts +881 -0
- package/src/agent/runtime/runtime.usage-forwarding.test.ts +229 -0
- package/src/agent/runtime/source-modules.test.ts +57 -0
- package/src/agent/runtime/source-modules.ts +353 -0
- package/src/agent/runtime/tool-call-buffer.test.ts +65 -0
- package/src/agent/runtime/tool-call-buffer.ts +60 -0
- package/src/agent/runtime/tool-confirmation.test.ts +56 -0
- package/src/agent/runtime/tool-confirmation.ts +15 -0
- package/src/agent/runtime/types.ts +99 -0
- package/src/commands/slash-commands.test.ts +216 -0
- package/src/commands/slash-commands.ts +64 -0
- package/src/components/chat/assistant-reply.test.tsx +47 -0
- package/src/components/chat/assistant-reply.tsx +136 -0
- package/src/components/chat/assistant-segment.test.ts +99 -0
- package/src/components/chat/assistant-segment.tsx +125 -0
- package/src/components/chat/assistant-tool-group.tsx +900 -0
- package/src/components/chat/code-block.test.tsx +206 -0
- package/src/components/chat/code-block.tsx +313 -0
- package/src/components/chat/prompt-card.tsx +81 -0
- package/src/components/chat/segment-groups.test.ts +52 -0
- package/src/components/chat/segment-groups.ts +106 -0
- package/src/components/chat/turn-item.tsx +39 -0
- package/src/components/conversation-panel.tsx +43 -0
- package/src/components/file-mention-menu.tsx +77 -0
- package/src/components/file-picker-dialog.tsx +206 -0
- package/src/components/footer-hints.tsx +75 -0
- package/src/components/model-picker-dialog.tsx +248 -0
- package/src/components/prompt.tsx +233 -0
- package/src/components/slash-command-menu.tsx +65 -0
- package/src/components/tool-confirm-dialog-content.test.ts +103 -0
- package/src/components/tool-confirm-dialog-content.ts +186 -0
- package/src/components/tool-confirm-dialog.tsx +187 -0
- package/src/components/tool-display-config.ts +119 -0
- package/src/context-usage-regressions.test.ts +26 -0
- package/src/files/attachment-capabilities.test.ts +30 -0
- package/src/files/attachment-capabilities.ts +50 -0
- package/src/files/attachment-content.ts +153 -0
- package/src/files/file-mention-query.test.ts +34 -0
- package/src/files/file-mention-query.ts +32 -0
- package/src/files/prompt-display.ts +13 -0
- package/src/files/types.ts +5 -0
- package/src/files/workspace-files.ts +63 -0
- package/src/hooks/agent-event-handlers.test.ts +207 -0
- package/src/hooks/agent-event-handlers.ts +196 -0
- package/src/hooks/chat-local-replies.fixed.test.ts +119 -0
- package/src/hooks/chat-local-replies.test.ts +153 -0
- package/src/hooks/chat-local-replies.ts +63 -0
- package/src/hooks/turn-updater.test.ts +70 -0
- package/src/hooks/turn-updater.ts +166 -0
- package/src/hooks/use-agent-chat.context.test.ts +10 -0
- package/src/hooks/use-agent-chat.status.test.ts +14 -0
- package/src/hooks/use-agent-chat.test.ts +80 -0
- package/src/hooks/use-agent-chat.ts +621 -0
- package/src/hooks/use-file-mention-menu.ts +196 -0
- package/src/hooks/use-file-picker.ts +185 -0
- package/src/hooks/use-model-picker.ts +196 -0
- package/src/hooks/use-slash-command-menu.ts +154 -0
- package/src/index.tsx +55 -0
- package/src/runtime/clipboard.test.ts +43 -0
- package/src/runtime/clipboard.ts +89 -0
- package/src/runtime/exit.test.ts +177 -0
- package/src/runtime/exit.ts +98 -0
- package/src/runtime/runtime-support.test.ts +31 -0
- package/src/runtime/terminal-theme.test.ts +55 -0
- package/src/runtime/terminal-theme.ts +196 -0
- package/src/types/chat.ts +32 -0
- package/src/types/message-content.ts +48 -0
- package/src/ui/open-code-theme.ts +176 -0
- package/src/ui/opencode-markdown.ts +211 -0
- package/src/ui/theme.simple.test.ts +52 -0
- package/src/ui/theme.test.ts +151 -0
- package/src/ui/theme.ts +152 -0
- package/src/utils/time.test.ts +144 -0
- package/src/utils/time.ts +7 -0
- package/tsconfig.json +30 -0
- package/LICENSE +0 -21
- package/dist/App.d.ts +0 -2
- package/dist/App.d.ts.map +0 -1
- package/dist/App.js +0 -170
- package/dist/App.js.map +0 -1
- package/dist/agent/prompts/system.d.ts +0 -24
- package/dist/agent/prompts/system.d.ts.map +0 -1
- package/dist/agent/prompts/system.js +0 -222
- package/dist/agent/prompts/system.js.map +0 -1
- package/dist/agent/runtime/event-format.d.ts +0 -17
- package/dist/agent/runtime/event-format.d.ts.map +0 -1
- package/dist/agent/runtime/event-format.js +0 -194
- package/dist/agent/runtime/event-format.js.map +0 -1
- package/dist/agent/runtime/model-types.d.ts +0 -13
- package/dist/agent/runtime/model-types.d.ts.map +0 -1
- package/dist/agent/runtime/model-types.js +0 -1
- package/dist/agent/runtime/model-types.js.map +0 -1
- package/dist/agent/runtime/runtime.d.ts +0 -16
- package/dist/agent/runtime/runtime.d.ts.map +0 -1
- package/dist/agent/runtime/runtime.js +0 -691
- package/dist/agent/runtime/runtime.js.map +0 -1
- package/dist/agent/runtime/source-modules.d.ts +0 -176
- package/dist/agent/runtime/source-modules.d.ts.map +0 -1
- package/dist/agent/runtime/source-modules.js +0 -110
- package/dist/agent/runtime/source-modules.js.map +0 -1
- package/dist/agent/runtime/tool-call-buffer.d.ts +0 -12
- package/dist/agent/runtime/tool-call-buffer.d.ts.map +0 -1
- package/dist/agent/runtime/tool-call-buffer.js +0 -48
- package/dist/agent/runtime/tool-call-buffer.js.map +0 -1
- package/dist/agent/runtime/tool-confirmation.d.ts +0 -3
- package/dist/agent/runtime/tool-confirmation.d.ts.map +0 -1
- package/dist/agent/runtime/tool-confirmation.js +0 -9
- package/dist/agent/runtime/tool-confirmation.js.map +0 -1
- package/dist/agent/runtime/types.d.ts +0 -86
- package/dist/agent/runtime/types.d.ts.map +0 -1
- package/dist/agent/runtime/types.js +0 -1
- package/dist/agent/runtime/types.js.map +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -12
- package/dist/cli.js.map +0 -1
- package/dist/commands/slash-commands.d.ts +0 -11
- package/dist/commands/slash-commands.d.ts.map +0 -1
- package/dist/commands/slash-commands.js +0 -48
- package/dist/commands/slash-commands.js.map +0 -1
- package/dist/components/chat/assistant-reply.d.ts +0 -13
- package/dist/components/chat/assistant-reply.d.ts.map +0 -1
- package/dist/components/chat/assistant-reply.js +0 -78
- package/dist/components/chat/assistant-reply.js.map +0 -1
- package/dist/components/chat/assistant-segment.d.ts +0 -8
- package/dist/components/chat/assistant-segment.d.ts.map +0 -1
- package/dist/components/chat/assistant-segment.js +0 -54
- package/dist/components/chat/assistant-segment.js.map +0 -1
- package/dist/components/chat/assistant-tool-group.d.ts +0 -7
- package/dist/components/chat/assistant-tool-group.d.ts.map +0 -1
- package/dist/components/chat/assistant-tool-group.js +0 -695
- package/dist/components/chat/assistant-tool-group.js.map +0 -1
- package/dist/components/chat/code-block.d.ts +0 -16
- package/dist/components/chat/code-block.d.ts.map +0 -1
- package/dist/components/chat/code-block.js +0 -194
- package/dist/components/chat/code-block.js.map +0 -1
- package/dist/components/chat/prompt-card.d.ts +0 -9
- package/dist/components/chat/prompt-card.d.ts.map +0 -1
- package/dist/components/chat/prompt-card.js +0 -18
- package/dist/components/chat/prompt-card.js.map +0 -1
- package/dist/components/chat/segment-groups.d.ts +0 -24
- package/dist/components/chat/segment-groups.d.ts.map +0 -1
- package/dist/components/chat/segment-groups.js +0 -69
- package/dist/components/chat/segment-groups.js.map +0 -1
- package/dist/components/chat/turn-item.d.ts +0 -9
- package/dist/components/chat/turn-item.d.ts.map +0 -1
- package/dist/components/chat/turn-item.js +0 -11
- package/dist/components/chat/turn-item.js.map +0 -1
- package/dist/components/conversation-panel.d.ts +0 -8
- package/dist/components/conversation-panel.d.ts.map +0 -1
- package/dist/components/conversation-panel.js +0 -8
- package/dist/components/conversation-panel.js.map +0 -1
- package/dist/components/file-mention-menu.d.ts +0 -11
- package/dist/components/file-mention-menu.d.ts.map +0 -1
- package/dist/components/file-mention-menu.js +0 -15
- package/dist/components/file-mention-menu.js.map +0 -1
- package/dist/components/file-picker-dialog.d.ts +0 -21
- package/dist/components/file-picker-dialog.d.ts.map +0 -1
- package/dist/components/file-picker-dialog.js +0 -48
- package/dist/components/file-picker-dialog.js.map +0 -1
- package/dist/components/footer-hints.d.ts +0 -7
- package/dist/components/footer-hints.d.ts.map +0 -1
- package/dist/components/footer-hints.js +0 -29
- package/dist/components/footer-hints.js.map +0 -1
- package/dist/components/model-picker-dialog.d.ts +0 -20
- package/dist/components/model-picker-dialog.d.ts.map +0 -1
- package/dist/components/model-picker-dialog.js +0 -72
- package/dist/components/model-picker-dialog.js.map +0 -1
- package/dist/components/prompt.d.ts +0 -18
- package/dist/components/prompt.d.ts.map +0 -1
- package/dist/components/prompt.js +0 -96
- package/dist/components/prompt.js.map +0 -1
- package/dist/components/slash-command-menu.d.ts +0 -9
- package/dist/components/slash-command-menu.d.ts.map +0 -1
- package/dist/components/slash-command-menu.js +0 -20
- package/dist/components/slash-command-menu.js.map +0 -1
- package/dist/components/tool-confirm-dialog-content.d.ts +0 -15
- package/dist/components/tool-confirm-dialog-content.d.ts.map +0 -1
- package/dist/components/tool-confirm-dialog-content.js +0 -143
- package/dist/components/tool-confirm-dialog-content.js.map +0 -1
- package/dist/components/tool-confirm-dialog.d.ts +0 -12
- package/dist/components/tool-confirm-dialog.d.ts.map +0 -1
- package/dist/components/tool-confirm-dialog.js +0 -21
- package/dist/components/tool-confirm-dialog.js.map +0 -1
- package/dist/components/tool-display-config.d.ts +0 -11
- package/dist/components/tool-display-config.d.ts.map +0 -1
- package/dist/components/tool-display-config.js +0 -94
- package/dist/components/tool-display-config.js.map +0 -1
- package/dist/config/paths.d.ts +0 -7
- package/dist/config/paths.d.ts.map +0 -1
- package/dist/config/paths.js +0 -24
- package/dist/config/paths.js.map +0 -1
- package/dist/files/attachment-capabilities.d.ts +0 -19
- package/dist/files/attachment-capabilities.d.ts.map +0 -1
- package/dist/files/attachment-capabilities.js +0 -26
- package/dist/files/attachment-capabilities.js.map +0 -1
- package/dist/files/attachment-content.d.ts +0 -5
- package/dist/files/attachment-content.d.ts.map +0 -1
- package/dist/files/attachment-content.js +0 -117
- package/dist/files/attachment-content.js.map +0 -1
- package/dist/files/file-mention-query.d.ts +0 -9
- package/dist/files/file-mention-query.d.ts.map +0 -1
- package/dist/files/file-mention-query.js +0 -23
- package/dist/files/file-mention-query.js.map +0 -1
- package/dist/files/prompt-display.d.ts +0 -3
- package/dist/files/prompt-display.d.ts.map +0 -1
- package/dist/files/prompt-display.js +0 -11
- package/dist/files/prompt-display.js.map +0 -1
- package/dist/files/types.d.ts +0 -6
- package/dist/files/types.d.ts.map +0 -1
- package/dist/files/types.js +0 -1
- package/dist/files/types.js.map +0 -1
- package/dist/files/workspace-files.d.ts +0 -3
- package/dist/files/workspace-files.d.ts.map +0 -1
- package/dist/files/workspace-files.js +0 -48
- package/dist/files/workspace-files.js.map +0 -1
- package/dist/hooks/agent-event-handlers.d.ts +0 -11
- package/dist/hooks/agent-event-handlers.d.ts.map +0 -1
- package/dist/hooks/agent-event-handlers.js +0 -137
- package/dist/hooks/agent-event-handlers.js.map +0 -1
- package/dist/hooks/chat-local-replies.d.ts +0 -9
- package/dist/hooks/chat-local-replies.d.ts.map +0 -1
- package/dist/hooks/chat-local-replies.js +0 -54
- package/dist/hooks/chat-local-replies.js.map +0 -1
- package/dist/hooks/turn-updater.d.ts +0 -9
- package/dist/hooks/turn-updater.d.ts.map +0 -1
- package/dist/hooks/turn-updater.js +0 -103
- package/dist/hooks/turn-updater.js.map +0 -1
- package/dist/hooks/use-agent-chat.d.ts +0 -29
- package/dist/hooks/use-agent-chat.d.ts.map +0 -1
- package/dist/hooks/use-agent-chat.js +0 -455
- package/dist/hooks/use-agent-chat.js.map +0 -1
- package/dist/hooks/use-file-mention-menu.d.ts +0 -22
- package/dist/hooks/use-file-mention-menu.d.ts.map +0 -1
- package/dist/hooks/use-file-mention-menu.js +0 -137
- package/dist/hooks/use-file-mention-menu.js.map +0 -1
- package/dist/hooks/use-file-picker.d.ts +0 -21
- package/dist/hooks/use-file-picker.d.ts.map +0 -1
- package/dist/hooks/use-file-picker.js +0 -145
- package/dist/hooks/use-file-picker.js.map +0 -1
- package/dist/hooks/use-model-picker.d.ts +0 -23
- package/dist/hooks/use-model-picker.d.ts.map +0 -1
- package/dist/hooks/use-model-picker.js +0 -151
- package/dist/hooks/use-model-picker.js.map +0 -1
- package/dist/hooks/use-slash-command-menu.d.ts +0 -19
- package/dist/hooks/use-slash-command-menu.d.ts.map +0 -1
- package/dist/hooks/use-slash-command-menu.js +0 -101
- package/dist/hooks/use-slash-command-menu.js.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -6
- package/dist/index.js.map +0 -1
- package/dist/run-cli-app.d.ts +0 -2
- package/dist/run-cli-app.d.ts.map +0 -1
- package/dist/run-cli-app.js +0 -41
- package/dist/run-cli-app.js.map +0 -1
- package/dist/runtime/clipboard.d.ts +0 -10
- package/dist/runtime/clipboard.d.ts.map +0 -1
- package/dist/runtime/clipboard.js +0 -64
- package/dist/runtime/clipboard.js.map +0 -1
- package/dist/runtime/exit.d.ts +0 -7
- package/dist/runtime/exit.d.ts.map +0 -1
- package/dist/runtime/exit.js +0 -85
- package/dist/runtime/exit.js.map +0 -1
- package/dist/runtime/runtime-support.d.ts +0 -4
- package/dist/runtime/runtime-support.d.ts.map +0 -1
- package/dist/runtime/runtime-support.js +0 -19
- package/dist/runtime/runtime-support.js.map +0 -1
- package/dist/runtime/terminal-theme.d.ts +0 -25
- package/dist/runtime/terminal-theme.d.ts.map +0 -1
- package/dist/runtime/terminal-theme.js +0 -148
- package/dist/runtime/terminal-theme.js.map +0 -1
- package/dist/types/chat.d.ts +0 -29
- package/dist/types/chat.d.ts.map +0 -1
- package/dist/types/chat.js +0 -1
- package/dist/types/chat.js.map +0 -1
- package/dist/types/message-content.d.ts +0 -38
- package/dist/types/message-content.d.ts.map +0 -1
- package/dist/types/message-content.js +0 -1
- package/dist/types/message-content.js.map +0 -1
- package/dist/ui/open-code-theme.d.ts +0 -58
- package/dist/ui/open-code-theme.d.ts.map +0 -1
- package/dist/ui/open-code-theme.js +0 -113
- package/dist/ui/open-code-theme.js.map +0 -1
- package/dist/ui/opencode-markdown.d.ts +0 -7
- package/dist/ui/opencode-markdown.d.ts.map +0 -1
- package/dist/ui/opencode-markdown.js +0 -169
- package/dist/ui/opencode-markdown.js.map +0 -1
- package/dist/ui/theme.d.ts +0 -68
- package/dist/ui/theme.d.ts.map +0 -1
- package/dist/ui/theme.js +0 -80
- package/dist/ui/theme.js.map +0 -1
- package/dist/utils/time.d.ts +0 -2
- package/dist/utils/time.d.ts.map +0 -1
- package/dist/utils/time.js +0 -7
- 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
|
+
};
|