@mcp-elements/angular 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +22 -0
  3. package/src/accordion.component.ts +74 -0
  4. package/src/ai-badge.component.ts +26 -0
  5. package/src/alert.component.ts +25 -0
  6. package/src/avatar.component.ts +24 -0
  7. package/src/badge.component.ts +17 -0
  8. package/src/button.component.ts +27 -0
  9. package/src/card.component.ts +46 -0
  10. package/src/chat-bubble.component.ts +53 -0
  11. package/src/chips.component.ts +33 -0
  12. package/src/counter.component.ts +48 -0
  13. package/src/dialog.component.ts +42 -0
  14. package/src/drawer.component.ts +48 -0
  15. package/src/dropdown-menu.component.ts +62 -0
  16. package/src/feedback.component.ts +71 -0
  17. package/src/index.ts +86 -0
  18. package/src/input.component.ts +46 -0
  19. package/src/loader.component.ts +12 -0
  20. package/src/mcp/index.ts +9 -0
  21. package/src/mcp/mcp-app-frame.component.ts +60 -0
  22. package/src/mcp/mcp-consent-dialog.component.ts +63 -0
  23. package/src/mcp/mcp-resource-browser.component.ts +86 -0
  24. package/src/mcp/mcp-scope-inspector.component.ts +81 -0
  25. package/src/mcp/mcp-server-status.component.ts +44 -0
  26. package/src/mcp/mcp-tool-call.component.ts +105 -0
  27. package/src/mcp/mcp-tool-form.component.ts +127 -0
  28. package/src/password-input.component.ts +35 -0
  29. package/src/popover.component.ts +40 -0
  30. package/src/progress.component.ts +20 -0
  31. package/src/prompt-input.component.ts +70 -0
  32. package/src/select.component.ts +106 -0
  33. package/src/separator.component.ts +15 -0
  34. package/src/skeleton.component.ts +11 -0
  35. package/src/source-card.component.ts +34 -0
  36. package/src/streaming-text.component.ts +43 -0
  37. package/src/suggestion-chips.component.ts +23 -0
  38. package/src/switch.component.ts +32 -0
  39. package/src/tabs.component.ts +95 -0
  40. package/src/textarea.component.ts +22 -0
  41. package/src/toast.component.ts +62 -0
  42. package/src/tooltip.directive.ts +63 -0
package/src/index.ts ADDED
@@ -0,0 +1,86 @@
1
+ // CSS-only components
2
+ export { SnxButtonComponent } from './button.component'
3
+ export { SnxBadgeComponent } from './badge.component'
4
+ export {
5
+ SnxCardComponent,
6
+ SnxCardHeaderComponent,
7
+ SnxCardTitleComponent,
8
+ SnxCardDescriptionComponent,
9
+ SnxCardContentComponent,
10
+ SnxCardFooterComponent,
11
+ } from './card.component'
12
+ export { SnxInputComponent } from './input.component'
13
+ export { SnxTextareaComponent } from './textarea.component'
14
+ export { SnxAvatarComponent } from './avatar.component'
15
+ export { SnxSeparatorComponent } from './separator.component'
16
+ export { SnxSkeletonComponent } from './skeleton.component'
17
+
18
+ // Interactive components
19
+ export { SnxDialogComponent } from './dialog.component'
20
+ export {
21
+ SnxTabsComponent,
22
+ SnxTabsListComponent,
23
+ SnxTabsTriggerComponent,
24
+ SnxTabsContentComponent,
25
+ } from './tabs.component'
26
+ export {
27
+ SnxAccordionComponent,
28
+ SnxAccordionItemComponent,
29
+ SnxAccordionTriggerComponent,
30
+ SnxAccordionContentComponent,
31
+ } from './accordion.component'
32
+ export { SnxSelectComponent } from './select.component'
33
+ export { SnxTooltipDirective } from './tooltip.directive'
34
+ export { SnxPopoverComponent } from './popover.component'
35
+
36
+ // New components
37
+ export { SnxToasterComponent, SnxToastService } from './toast.component'
38
+ export { SnxDrawerComponent, SnxDrawerHeaderComponent, SnxDrawerFooterComponent, SnxDrawerTitleComponent, SnxDrawerDescriptionComponent, SnxDrawerBodyComponent } from './drawer.component'
39
+ export { SnxDropdownMenuComponent } from './dropdown-menu.component'
40
+ export { SnxSwitchComponent } from './switch.component'
41
+ export { SnxProgressComponent } from './progress.component'
42
+ export { SnxLoaderComponent } from './loader.component'
43
+ export { SnxChipComponent, SnxChipsComponent } from './chips.component'
44
+ export { SnxPasswordInputComponent } from './password-input.component'
45
+ export { SnxCounterComponent } from './counter.component'
46
+ export { SnxAlertComponent, SnxAlertTitleComponent, SnxAlertDescriptionComponent } from './alert.component'
47
+
48
+ // AI components
49
+ export {
50
+ SnxPromptInputComponent,
51
+ SnxPromptInputTextareaComponent,
52
+ SnxPromptInputFooterComponent,
53
+ SnxPromptInputActionsComponent,
54
+ SnxPromptInputCharCountComponent,
55
+ SnxPromptInputAttachmentsComponent,
56
+ SnxPromptInputAttachmentComponent,
57
+ } from './prompt-input.component'
58
+ export {
59
+ SnxChatBubbleComponent,
60
+ SnxChatBubbleAvatarComponent,
61
+ SnxChatBubbleContentComponent,
62
+ SnxChatBubbleTimestampComponent,
63
+ SnxChatBubbleTypingComponent,
64
+ } from './chat-bubble.component'
65
+ export { SnxAiBadgeComponent } from './ai-badge.component'
66
+ export { SnxSuggestionChipsComponent, SnxSuggestionChipComponent } from './suggestion-chips.component'
67
+ export { SnxSourceCardsComponent, SnxSourceCardComponent } from './source-card.component'
68
+ export {
69
+ SnxStreamingTextComponent,
70
+ SnxStreamingTextFadeInComponent,
71
+ SnxStreamingTextWordComponent,
72
+ SnxStreamingTextLineComponent,
73
+ SnxStreamingTextSkeletonComponent,
74
+ SnxStreamingTextSkeletonLineComponent,
75
+ } from './streaming-text.component'
76
+ export {
77
+ SnxFeedbackComponent,
78
+ SnxFeedbackButtonComponent,
79
+ SnxFeedbackSeparatorComponent,
80
+ SnxFeedbackFormComponent,
81
+ SnxFeedbackInputComponent,
82
+ SnxFeedbackSubmitComponent,
83
+ } from './feedback.component'
84
+
85
+ // MCP components
86
+ export * from './mcp'
@@ -0,0 +1,46 @@
1
+ import { Component, input, computed, output, forwardRef } from '@angular/core'
2
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3
+
4
+ @Component({
5
+ selector: 'mcpe-input',
6
+ standalone: true,
7
+ template: `
8
+ <input
9
+ [class]="classes()"
10
+ [type]="type()"
11
+ [placeholder]="placeholder()"
12
+ [disabled]="disabled()"
13
+ [value]="value()"
14
+ (input)="onInput($event)"
15
+ (blur)="onTouched()"
16
+ />
17
+ `,
18
+ providers: [
19
+ {
20
+ provide: NG_VALUE_ACCESSOR,
21
+ useExisting: forwardRef(() => SnxInputComponent),
22
+ multi: true,
23
+ },
24
+ ],
25
+ })
26
+ export class SnxInputComponent implements ControlValueAccessor {
27
+ type = input<string>('text')
28
+ placeholder = input('')
29
+ disabled = input(false)
30
+ value = input('')
31
+ class = input('')
32
+
33
+ classes = computed(() => ['mcpe-input', this.class()].filter(Boolean).join(' '))
34
+
35
+ private onChange: (value: string) => void = () => {}
36
+ onTouched: () => void = () => {}
37
+
38
+ onInput(event: Event) {
39
+ const value = (event.target as HTMLInputElement).value
40
+ this.onChange(value)
41
+ }
42
+
43
+ writeValue(value: string): void {}
44
+ registerOnChange(fn: (value: string) => void): void { this.onChange = fn }
45
+ registerOnTouched(fn: () => void): void { this.onTouched = fn }
46
+ }
@@ -0,0 +1,12 @@
1
+ import { Component, input, computed } from '@angular/core'
2
+
3
+ @Component({
4
+ selector: 'mcpe-loader',
5
+ standalone: true,
6
+ template: `<div [class]="classes()" role="status" aria-label="Loading"><span class="sr-only">Loading...</span></div>`,
7
+ })
8
+ export class SnxLoaderComponent {
9
+ size = input<'sm' | 'md' | 'lg' | 'xl'>('md')
10
+ variant = input<'primary' | 'muted'>('primary')
11
+ classes = computed(() => `mcpe-loader mcpe-loader-${this.size()} mcpe-loader-${this.variant()}`)
12
+ }
@@ -0,0 +1,9 @@
1
+ export { McpeMcpServerStatusComponent } from './mcp-server-status.component'
2
+ export type { McpConnectionStatus } from './mcp-server-status.component'
3
+ export { McpeMcpToolCallComponent } from './mcp-tool-call.component'
4
+ export { McpeMcpToolFormComponent } from './mcp-tool-form.component'
5
+ export { McpeMcpConsentDialogComponent } from './mcp-consent-dialog.component'
6
+ export { McpeMcpScopeInspectorComponent } from './mcp-scope-inspector.component'
7
+ export { McpeMcpResourceBrowserComponent } from './mcp-resource-browser.component'
8
+ export type { McpResource } from './mcp-resource-browser.component'
9
+ export { McpeMcpAppFrameComponent } from './mcp-app-frame.component'
@@ -0,0 +1,60 @@
1
+ import { Component, input, output, effect, computed, ElementRef, viewChild, OnDestroy } from '@angular/core'
2
+ import { cn, createAppBridge } from '@mcp-elements/core'
3
+ import type { AppMessageEnvelope } from '@mcp-elements/core'
4
+
5
+ @Component({
6
+ selector: 'mcpe-mcp-app-frame',
7
+ standalone: true,
8
+ template: `
9
+ <div [class]="classes()">
10
+ <iframe
11
+ #frame
12
+ [src]="src()"
13
+ [sandbox]="sandbox()"
14
+ [style.height.px]="height()"
15
+ title="MCP App"
16
+ aria-label="MCP App frame"
17
+ style="display:block;width:100%;border:none"
18
+ ></iframe>
19
+ </div>
20
+ `,
21
+ })
22
+ export class McpeMcpAppFrameComponent implements OnDestroy {
23
+ src = input.required<string>()
24
+ height = input(480)
25
+ sandbox = input('allow-scripts allow-same-origin')
26
+ class = input('')
27
+ onMessage = output<AppMessageEnvelope>()
28
+
29
+ frame = viewChild<ElementRef<HTMLIFrameElement>>('frame')
30
+
31
+ private _unsub: (() => void) | undefined
32
+ private _removeListener: (() => void) | undefined
33
+
34
+ constructor() {
35
+ effect(() => {
36
+ this._cleanup()
37
+ const bridge = createAppBridge({
38
+ postMessage: (env: AppMessageEnvelope) => {
39
+ this.frame()?.nativeElement?.contentWindow?.postMessage(env, '*')
40
+ },
41
+ })
42
+ const unsub = bridge.onMessage((env: AppMessageEnvelope) => this.onMessage.emit(env))
43
+ const handler = (e: MessageEvent) => bridge.receive(e.data)
44
+ window.addEventListener('message', handler)
45
+ this._unsub = unsub
46
+ this._removeListener = () => window.removeEventListener('message', handler)
47
+ })
48
+ }
49
+
50
+ private _cleanup() {
51
+ this._unsub?.()
52
+ this._removeListener?.()
53
+ this._unsub = undefined
54
+ this._removeListener = undefined
55
+ }
56
+
57
+ ngOnDestroy() { this._cleanup() }
58
+
59
+ classes = computed(() => cn('mcpe-mcp-app-frame', this.class()))
60
+ }
@@ -0,0 +1,63 @@
1
+ import { Component, input, output, computed } from '@angular/core'
2
+ import { CommonModule } from '@angular/common'
3
+ import { parseScopes } from '@mcp-elements/core'
4
+
5
+ @Component({
6
+ selector: 'mcpe-mcp-consent-dialog',
7
+ standalone: true,
8
+ imports: [CommonModule],
9
+ template: `
10
+ @if (open()) {
11
+ <div class="mcpe-dialog-overlay" (click)="deny()"></div>
12
+ <div class="mcpe-dialog-content" role="dialog" aria-modal="true" [attr.aria-label]="'Allow ' + serverName() + '?'">
13
+ <!-- Server info -->
14
+ <div class="mcpe-mcp-consent-dialog-server">
15
+ <div class="mcpe-mcp-consent-dialog-icon" aria-hidden="true">
16
+ @if (serverIcon()) {
17
+ <img [src]="serverIcon()" alt="" class="h-full w-full object-cover" />
18
+ } @else {
19
+ {{ serverName()[0]?.toUpperCase() ?? '?' }}
20
+ }
21
+ </div>
22
+ <div>
23
+ <p class="mcpe-mcp-consent-dialog-server-name">{{ serverName() }}</p>
24
+ <p class="mcpe-mcp-consent-dialog-server-meta">is requesting access to</p>
25
+ </div>
26
+ </div>
27
+ <!-- Scopes -->
28
+ <div class="mcpe-mcp-consent-dialog-scopes" role="list" aria-label="Requested permissions">
29
+ @for (s of parsedScopes(); track s.raw) {
30
+ <div class="mcpe-mcp-consent-dialog-scope-item" role="listitem">
31
+ <div class="flex-1 min-w-0">
32
+ <p class="mcpe-mcp-consent-dialog-scope-resource">{{ s.resource }}</p>
33
+ <div class="mcpe-mcp-consent-dialog-scope-perms">
34
+ @for (p of s.permissions; track p) {
35
+ <span class="mcpe-mcp-consent-dialog-scope-perm">{{ p }}</span>
36
+ }
37
+ </div>
38
+ </div>
39
+ </div>
40
+ }
41
+ </div>
42
+ <!-- Actions -->
43
+ <div class="mcpe-mcp-consent-dialog-actions">
44
+ <button class="mcpe-btn mcpe-btn-outline flex-1" (click)="deny()">Deny</button>
45
+ <button class="mcpe-btn mcpe-btn-primary flex-1" (click)="approve()">Allow</button>
46
+ </div>
47
+ </div>
48
+ }
49
+ `,
50
+ })
51
+ export class McpeMcpConsentDialogComponent {
52
+ open = input.required<boolean>()
53
+ serverName = input.required<string>()
54
+ serverIcon = input<string>()
55
+ scopes = input<string[]>([])
56
+ onApprove = output<void>()
57
+ onDeny = output<void>()
58
+
59
+ parsedScopes = computed(() => parseScopes(this.scopes().join(' ')))
60
+
61
+ approve() { this.onApprove.emit() }
62
+ deny() { this.onDeny.emit() }
63
+ }
@@ -0,0 +1,86 @@
1
+ import { Component, input, output, computed } from '@angular/core'
2
+ import { CommonModule } from '@angular/common'
3
+ import { cn } from '@mcp-elements/core'
4
+
5
+ export interface McpResource {
6
+ uri: string
7
+ name: string
8
+ mimeType?: string
9
+ description?: string
10
+ }
11
+
12
+ function mimeTypeLabel(mimeType?: string): string {
13
+ if (!mimeType) return 'res'
14
+ if (mimeType.includes('json')) return 'json'
15
+ if (mimeType.includes('text')) return 'txt'
16
+ if (mimeType.includes('image')) return 'img'
17
+ if (mimeType.includes('pdf')) return 'pdf'
18
+ return mimeType.split('/')[1]?.slice(0, 4) ?? 'res'
19
+ }
20
+
21
+ @Component({
22
+ selector: 'mcpe-mcp-resource-browser',
23
+ standalone: true,
24
+ imports: [CommonModule],
25
+ template: `
26
+ @if (loading()) {
27
+ <div [class]="classes()">
28
+ @for (n of skeletonItems; track n) {
29
+ <div class="flex items-center gap-3 px-3 py-2.5">
30
+ <div class="h-8 w-8 rounded-md animate-pulse bg-muted"></div>
31
+ <div class="h-4 flex-1 rounded animate-pulse bg-muted"></div>
32
+ </div>
33
+ }
34
+ </div>
35
+ } @else if (resources().length === 0) {
36
+ <div [class]="classes()">
37
+ <p class="mcpe-mcp-resource-browser-empty">No resources available</p>
38
+ </div>
39
+ } @else {
40
+ <div [class]="classes()" role="list">
41
+ @for (r of resources(); track r.uri) {
42
+ <button
43
+ type="button"
44
+ role="listitem"
45
+ [class]="itemClass(r.uri)"
46
+ [attr.aria-selected]="selectedUri() === r.uri"
47
+ [attr.aria-label]="r.name"
48
+ (click)="select(r)"
49
+ >
50
+ <span class="mcpe-mcp-resource-browser-icon" aria-hidden="true">{{ mimeLabel(r.mimeType) }}</span>
51
+ <span class="mcpe-mcp-resource-browser-name">{{ r.name }}</span>
52
+ @if (r.mimeType) {
53
+ <span class="mcpe-mcp-resource-browser-type">{{ r.mimeType.split('/')[0] }}</span>
54
+ }
55
+ </button>
56
+ }
57
+ </div>
58
+ }
59
+ `,
60
+ })
61
+ export class McpeMcpResourceBrowserComponent {
62
+ resources = input<McpResource[]>([])
63
+ selectedUri = input<string>()
64
+ loading = input(false)
65
+ class = input('')
66
+ onSelect = output<McpResource>()
67
+
68
+ readonly skeletonItems = [1, 2, 3, 4]
69
+
70
+ classes = computed(() => cn('mcpe-mcp-resource-browser', this.class()))
71
+
72
+ itemClass(uri: string): string {
73
+ return cn(
74
+ 'mcpe-mcp-resource-browser-item w-full text-left',
75
+ this.selectedUri() === uri ? 'mcpe-mcp-resource-browser-item-selected' : ''
76
+ )
77
+ }
78
+
79
+ mimeLabel(mimeType?: string): string {
80
+ return mimeTypeLabel(mimeType)
81
+ }
82
+
83
+ select(r: McpResource) {
84
+ this.onSelect.emit(r)
85
+ }
86
+ }
@@ -0,0 +1,81 @@
1
+ import { Component, input, signal, computed } from '@angular/core'
2
+ import { CommonModule } from '@angular/common'
3
+ import { cn, parseScopes } from '@mcp-elements/core'
4
+ import type { ScopeDescriptor } from '@mcp-elements/core'
5
+
6
+ @Component({
7
+ selector: 'mcpe-mcp-scope-inspector',
8
+ standalone: true,
9
+ imports: [CommonModule],
10
+ template: `
11
+ <div [class]="classes()" role="list">
12
+ @for (s of parsedScopes(); track s.raw) {
13
+ <div class="mcpe-mcp-scope-inspector-item" role="listitem">
14
+ <button
15
+ type="button"
16
+ class="mcpe-mcp-scope-inspector-trigger"
17
+ [attr.aria-expanded]="isOpen(s.raw)"
18
+ (click)="toggle(s.raw)"
19
+ >
20
+ <div class="flex items-center gap-3">
21
+ <span class="mcpe-mcp-scope-inspector-resource">{{ s.resource }}</span>
22
+ <div class="mcpe-mcp-scope-inspector-perms">
23
+ @for (p of s.permissions; track p) {
24
+ <span class="mcpe-mcp-scope-inspector-perm">{{ p }}</span>
25
+ }
26
+ </div>
27
+ </div>
28
+ <svg
29
+ [class]="chevronClass(s.raw)"
30
+ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
31
+ fill="none" stroke="currentColor" stroke-width="2"
32
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
33
+ <path d="m6 9 6 6 6-6"/>
34
+ </svg>
35
+ </button>
36
+ @if (isOpen(s.raw) && getDescription(s)) {
37
+ <div role="region" class="mcpe-mcp-scope-inspector-body">
38
+ {{ getDescription(s) }}
39
+ </div>
40
+ }
41
+ </div>
42
+ }
43
+ </div>
44
+ `,
45
+ })
46
+ export class McpeMcpScopeInspectorComponent {
47
+ scopes = input<string | ScopeDescriptor[]>('')
48
+ descriptions = input<Record<string, string>>({})
49
+ class = input('')
50
+
51
+ openKeys = signal<Set<string>>(new Set())
52
+
53
+ parsedScopes = computed((): ScopeDescriptor[] => {
54
+ const s = this.scopes()
55
+ return typeof s === 'string' ? parseScopes(s) : s
56
+ })
57
+
58
+ classes = computed(() => cn('mcpe-mcp-scope-inspector', this.class()))
59
+
60
+ toggle(key: string) {
61
+ this.openKeys.update((prev: Set<string>) => {
62
+ const next = new Set(prev)
63
+ if (next.has(key)) next.delete(key)
64
+ else next.add(key)
65
+ return next
66
+ })
67
+ }
68
+
69
+ isOpen(key: string): boolean {
70
+ return this.openKeys().has(key)
71
+ }
72
+
73
+ chevronClass(key: string): string {
74
+ return cn('mcpe-mcp-scope-inspector-chevron', this.isOpen(key) ? 'mcpe-mcp-scope-inspector-chevron-open' : '')
75
+ }
76
+
77
+ getDescription(s: ScopeDescriptor): string | undefined {
78
+ const d = this.descriptions()
79
+ return d[s.raw] ?? d[s.resource] ?? s.description
80
+ }
81
+ }
@@ -0,0 +1,44 @@
1
+ import { Component, input, computed } from '@angular/core'
2
+ import { cn } from '@mcp-elements/core'
3
+
4
+ export type McpConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error'
5
+
6
+ const STATUS_LABELS: Record<McpConnectionStatus, string> = {
7
+ connected: 'Connected',
8
+ connecting: 'Connecting',
9
+ disconnected: 'Disconnected',
10
+ error: 'Error',
11
+ }
12
+
13
+ @Component({
14
+ selector: 'mcpe-mcp-server-status',
15
+ standalone: true,
16
+ template: `
17
+ <span
18
+ [class]="classes()"
19
+ role="status"
20
+ aria-live="polite"
21
+ [attr.aria-label]="ariaLabel()"
22
+ >
23
+ <span class="mcpe-mcp-server-status-dot" aria-hidden="true"></span>
24
+ {{ label() }}
25
+ </span>
26
+ `,
27
+ })
28
+ export class McpeMcpServerStatusComponent {
29
+ status = input.required<McpConnectionStatus>()
30
+ serverName = input<string>()
31
+ class = input('')
32
+
33
+ classes = computed(() => cn('mcpe-mcp-server-status', `mcpe-mcp-server-status-${this.status()}`, this.class()))
34
+ label = computed(() => {
35
+ const s = this.serverName()
36
+ const statusLabel = STATUS_LABELS[this.status() as McpConnectionStatus]
37
+ return s ? `${s} · ${statusLabel}` : statusLabel
38
+ })
39
+ ariaLabel = computed(() => {
40
+ const s = this.serverName()
41
+ const statusLabel = STATUS_LABELS[this.status() as McpConnectionStatus]
42
+ return s ? `${s}: ${statusLabel}` : statusLabel
43
+ })
44
+ }
@@ -0,0 +1,105 @@
1
+ import { Component, input, output, signal, effect, computed, OnDestroy } from '@angular/core'
2
+ import { CommonModule } from '@angular/common'
3
+ import { cn } from '@mcp-elements/core'
4
+ import type { ToolStateApi, ToolStateSnapshot } from '@mcp-elements/core'
5
+
6
+ const STATUS_LABELS: Record<string, string> = {
7
+ idle: 'idle',
8
+ pending: 'pending',
9
+ running: 'running',
10
+ done: 'done',
11
+ error: 'error',
12
+ cancelled: 'cancelled',
13
+ }
14
+
15
+ @Component({
16
+ selector: 'mcpe-mcp-tool-call',
17
+ standalone: true,
18
+ imports: [CommonModule],
19
+ template: `
20
+ <div [class]="classes()">
21
+ <!-- Header -->
22
+ <div class="mcpe-mcp-tool-call-header">
23
+ <div class="mcpe-mcp-tool-call-name">
24
+ <span class="mcpe-mcp-tool-call-icon" aria-hidden="true">fn</span>
25
+ <span class="mcpe-mcp-tool-call-title">{{ displayName() }}</span>
26
+ </div>
27
+ <span [class]="badgeClass()">
28
+ @if (snap().status === 'running') {
29
+ <svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24" aria-hidden="true">
30
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
31
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
32
+ </svg>
33
+ }
34
+ {{ statusLabels[snap().status] }}
35
+ </span>
36
+ </div>
37
+ <!-- Args -->
38
+ @if (displayArgs()) {
39
+ <pre class="mcpe-mcp-tool-call-args">{{ displayArgs() | json }}</pre>
40
+ }
41
+ <!-- Progress bar -->
42
+ @if (snap().status === 'running') {
43
+ <div class="mcpe-mcp-tool-call-progress" role="progressbar" aria-label="Tool running">
44
+ <div class="mcpe-mcp-tool-call-progress-bar" style="width: 60%"></div>
45
+ </div>
46
+ }
47
+ <!-- Result -->
48
+ @if (snap().status === 'done' && snap().result) {
49
+ <div class="mcpe-mcp-tool-call-result mcpe-mcp-tool-call-result-done">
50
+ @for (block of textBlocks(); track $index) {
51
+ <p class="whitespace-pre-wrap text-sm">{{ block }}</p>
52
+ }
53
+ </div>
54
+ }
55
+ <!-- Error -->
56
+ @if (snap().status === 'error' && snap().error) {
57
+ <div class="mcpe-mcp-tool-call-result mcpe-mcp-tool-call-result-error">
58
+ <p class="text-sm">{{ snap().error?.message }}</p>
59
+ <button (click)="onRetry.emit()" class="text-xs underline underline-offset-2">Retry</button>
60
+ </div>
61
+ }
62
+ </div>
63
+ `,
64
+ })
65
+ export class McpeMcpToolCallComponent implements OnDestroy {
66
+ state = input.required<ToolStateApi>()
67
+ toolName = input<string>()
68
+ args = input<Record<string, unknown>>()
69
+ onRetry = output<void>()
70
+ class = input('')
71
+
72
+ readonly statusLabels = STATUS_LABELS
73
+
74
+ snap = signal<ToolStateSnapshot>({ status: 'idle' })
75
+ private _unsub: (() => void) | undefined
76
+
77
+ constructor() {
78
+ effect(() => {
79
+ this._unsub?.()
80
+ const s = this.state()
81
+ this.snap.set({
82
+ status: s.status,
83
+ tool: s.tool,
84
+ args: s.args,
85
+ result: s.result,
86
+ error: s.error,
87
+ startedAt: s.startedAt,
88
+ endedAt: s.endedAt,
89
+ })
90
+ this._unsub = s.subscribe((snapshot: ToolStateSnapshot) => this.snap.set({ ...snapshot }))
91
+ })
92
+ }
93
+
94
+ ngOnDestroy() { this._unsub?.() }
95
+
96
+ classes = computed(() => cn('mcpe-mcp-tool-call', this.class()))
97
+ badgeClass = computed(() => cn('mcpe-mcp-tool-call-badge', `mcpe-mcp-tool-call-badge-${this.snap().status}`))
98
+ displayName = computed(() => this.snap().tool ?? this.toolName() ?? 'unknown')
99
+ displayArgs = computed(() => this.snap().args ?? this.args())
100
+ textBlocks = computed(() =>
101
+ this.snap().result?.content
102
+ .filter((c: { type: string }) => c.type === 'text')
103
+ .map((c: { type: string; text?: string }) => (c as { type: 'text'; text: string }).text) ?? []
104
+ )
105
+ }