@demicodes/web-ui 0.3.1

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 (114) hide show
  1. package/package.json +55 -0
  2. package/src/agent/AgentMessageInput.vue +164 -0
  3. package/src/agent/AgentMessageList.vue +124 -0
  4. package/src/agent/AgentPanel.vue +18 -0
  5. package/src/agent/AgentRoot.vue +17 -0
  6. package/src/agent/AgentTabBar.vue +422 -0
  7. package/src/agent/AgentTabItem.vue +150 -0
  8. package/src/agent/ContextUsageIndicator.vue +136 -0
  9. package/src/agent/ConversationListDropdown.vue +71 -0
  10. package/src/agent/ConversationStatusDot.vue +26 -0
  11. package/src/agent/ConversationView.vue +82 -0
  12. package/src/agent/MessageQueueBar.vue +67 -0
  13. package/src/agent/ModelSelector.vue +125 -0
  14. package/src/agent/ReasoningSelector.vue +106 -0
  15. package/src/agent/SelectorTrigger.vue +17 -0
  16. package/src/agent/__tests__/block-helpers.test.ts +73 -0
  17. package/src/agent/__tests__/input-actions.test.ts +130 -0
  18. package/src/agent/__tests__/input-editor.test.ts +15 -0
  19. package/src/agent/__tests__/pending-steers.test.ts +99 -0
  20. package/src/agent/__tests__/queue-submit.test.ts +14 -0
  21. package/src/agent/__tests__/reasoning.test.ts +32 -0
  22. package/src/agent/__tests__/tail-loading.test.ts +131 -0
  23. package/src/agent/__tests__/tool-rendering.test.ts +35 -0
  24. package/src/agent/__tests__/visible-blocks.test.ts +125 -0
  25. package/src/agent/block-helpers.ts +59 -0
  26. package/src/agent/block-types.ts +12 -0
  27. package/src/agent/blocks/AbortedBlock.vue +26 -0
  28. package/src/agent/blocks/AgentMessageVirtualBlock.vue +88 -0
  29. package/src/agent/blocks/AnsiText.vue +75 -0
  30. package/src/agent/blocks/AssistantTextBlock.vue +34 -0
  31. package/src/agent/blocks/CompactionBlock.vue +36 -0
  32. package/src/agent/blocks/ErrorBlock.vue +79 -0
  33. package/src/agent/blocks/FunctionalBlock.vue +118 -0
  34. package/src/agent/blocks/LoadingBlock.vue +28 -0
  35. package/src/agent/blocks/ThinkingBlock.vue +86 -0
  36. package/src/agent/blocks/ToolCallBlock.vue +40 -0
  37. package/src/agent/blocks/ToolGenericBlock.vue +43 -0
  38. package/src/agent/blocks/ToolShellAbortBlock.vue +13 -0
  39. package/src/agent/blocks/ToolShellBlock.vue +47 -0
  40. package/src/agent/blocks/ToolShellControlBlock.vue +51 -0
  41. package/src/agent/blocks/ToolShellStatusBlock.vue +13 -0
  42. package/src/agent/blocks/ToolShellWriteBlock.vue +13 -0
  43. package/src/agent/blocks/ToolStatusBadge.vue +13 -0
  44. package/src/agent/blocks/ToolYieldBlock.vue +13 -0
  45. package/src/agent/blocks/UserBlock.vue +112 -0
  46. package/src/agent/conversation-runtime.ts +205 -0
  47. package/src/agent/conversation-status.ts +12 -0
  48. package/src/agent/message-input/input-model.ts +33 -0
  49. package/src/agent/message-input/useAgentInputActions.ts +96 -0
  50. package/src/agent/message-input/useAgentInputEditor.ts +90 -0
  51. package/src/agent/message-input/useAgentInputSessionState.ts +48 -0
  52. package/src/agent/pending-steers.ts +134 -0
  53. package/src/agent/providers/ProviderIcon.vue +32 -0
  54. package/src/agent/providers/icons/claude.svg +1 -0
  55. package/src/agent/providers/icons/openai.svg +3 -0
  56. package/src/agent/queue-submit.ts +4 -0
  57. package/src/agent/reasoning.ts +45 -0
  58. package/src/agent/tail-loading.ts +31 -0
  59. package/src/agent/thinking-streaming.ts +7 -0
  60. package/src/agent/tool-rendering.ts +59 -0
  61. package/src/agent/types.ts +39 -0
  62. package/src/agent/ui-options.ts +21 -0
  63. package/src/agent/visible-blocks.ts +25 -0
  64. package/src/agent/workspace.ts +355 -0
  65. package/src/composables/useBlockVirtualizer.ts +290 -0
  66. package/src/composables/useContextMenuOwner.ts +44 -0
  67. package/src/composables/useOverlay.ts +18 -0
  68. package/src/composables/useTerminalTheme.ts +75 -0
  69. package/src/env.d.ts +10 -0
  70. package/src/infra/errors.ts +29 -0
  71. package/src/infra/i18n.ts +41 -0
  72. package/src/markdown/filePath.ts +54 -0
  73. package/src/markdown/highlight.ts +139 -0
  74. package/src/markdown/md.ts +58 -0
  75. package/src/markdown/render.ts +117 -0
  76. package/src/markdown/types.ts +20 -0
  77. package/src/overlay/appOverlay.ts +3 -0
  78. package/src/overlay/overlayStore.ts +54 -0
  79. package/src/store/createStore.ts +33 -0
  80. package/src/store/useStore.ts +7 -0
  81. package/src/styles/base.css +342 -0
  82. package/src/theme/appTheme.ts +56 -0
  83. package/src/theme/codeThemes.ts +454 -0
  84. package/src/theme/themeStore.ts +33 -0
  85. package/src/transport/agent-socket.ts +20 -0
  86. package/src/transport/control-client.ts +64 -0
  87. package/src/transport/protocol.ts +57 -0
  88. package/src/ui/Button.vue +30 -0
  89. package/src/ui/Checkbox.vue +25 -0
  90. package/src/ui/ContextMenu.vue +33 -0
  91. package/src/ui/Dialog.vue +48 -0
  92. package/src/ui/DropdownMenu.vue +77 -0
  93. package/src/ui/ErrorBox.vue +43 -0
  94. package/src/ui/HighlightText.vue +39 -0
  95. package/src/ui/IconButton.vue +30 -0
  96. package/src/ui/IndeterminateSpinner.vue +72 -0
  97. package/src/ui/InlineError.vue +25 -0
  98. package/src/ui/LoadMore.vue +32 -0
  99. package/src/ui/MarkdownPreview.vue +31 -0
  100. package/src/ui/Menu.vue +8 -0
  101. package/src/ui/MenuDivider.vue +6 -0
  102. package/src/ui/MenuItem.vue +48 -0
  103. package/src/ui/OptionMenu.vue +8 -0
  104. package/src/ui/OptionMenuGroup.vue +16 -0
  105. package/src/ui/OptionMenuItem.vue +28 -0
  106. package/src/ui/Popover.vue +90 -0
  107. package/src/ui/ScrollToBottomButton.vue +32 -0
  108. package/src/ui/SelectDropdown.vue +231 -0
  109. package/src/ui/Switch.vue +41 -0
  110. package/src/ui/TextInput.vue +44 -0
  111. package/src/ui/ThemeToggle.vue +49 -0
  112. package/src/ui/ToggleSwitch.vue +34 -0
  113. package/src/ui/Tooltip.vue +143 -0
  114. package/src/ui/blankClickGuard.ts +12 -0
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ import { useAttrs } from 'vue'
3
+ import type { MessageListBlock } from '../pending-steers'
4
+ import UserBlock from './UserBlock.vue'
5
+ import ThinkingBlock from './ThinkingBlock.vue'
6
+ import AssistantTextBlock from './AssistantTextBlock.vue'
7
+ import ToolCallBlock from './ToolCallBlock.vue'
8
+ import ErrorBlock from './ErrorBlock.vue'
9
+ import AbortedBlock from './AbortedBlock.vue'
10
+ import CompactionBlock from './CompactionBlock.vue'
11
+
12
+ defineOptions({ inheritAttrs: false })
13
+
14
+ const props = defineProps<{
15
+ block: MessageListBlock
16
+ conversationId: string
17
+ isThinkingStreaming: boolean
18
+ thinkingEndedAt?: string | null
19
+ }>()
20
+
21
+ const emit = defineEmits<{
22
+ continue: []
23
+ retry: []
24
+ deletePendingSteer: [id: string]
25
+ }>()
26
+
27
+ const attrs = useAttrs()
28
+ </script>
29
+
30
+ <template>
31
+ <UserBlock
32
+ v-if="block.type === 'user'"
33
+ v-bind="attrs"
34
+ :content="block.content"
35
+ />
36
+ <UserBlock
37
+ v-else-if="block.type === 'steer'"
38
+ v-bind="attrs"
39
+ :content="block.content"
40
+ variant="steer"
41
+ />
42
+ <UserBlock
43
+ v-else-if="block.type === 'pending_steer'"
44
+ v-bind="attrs"
45
+ :content="block.content"
46
+ variant="steer"
47
+ pending
48
+ deletable
49
+ @delete="emit('deletePendingSteer', block.pendingSteerId)"
50
+ />
51
+ <div v-else v-bind="attrs">
52
+ <ThinkingBlock
53
+ v-if="block.type === 'thinking'"
54
+ :thinking="block.text"
55
+ :is-streaming="isThinkingStreaming"
56
+ :created-at="block.createdAt"
57
+ :ended-at="thinkingEndedAt"
58
+ />
59
+ <AssistantTextBlock
60
+ v-else-if="block.type === 'text'"
61
+ :content="block.text"
62
+ />
63
+ <div v-else-if="block.type === 'tool_call'" class="overflow-hidden px-[var(--agent-pad-x,2rem)]">
64
+ <ToolCallBlock :block="block" :conversation-id="props.conversationId" :is-streaming="block.status === 'executing'" />
65
+ </div>
66
+ <div v-else-if="block.type === 'error'" class="px-[var(--agent-pad-x,2rem)]">
67
+ <ErrorBlock
68
+ :message="block.message"
69
+ :code="block.code"
70
+ @continue="emit('continue')"
71
+ @retry="emit('retry')"
72
+ />
73
+ </div>
74
+ <div v-else-if="block.type === 'abort'" class="px-[var(--agent-pad-x,2rem)]">
75
+ <AbortedBlock
76
+ @continue="emit('continue')"
77
+ @retry="emit('retry')"
78
+ />
79
+ </div>
80
+ <CompactionBlock
81
+ v-else-if="block.type === 'compaction_boundary'"
82
+ :summary="block.summary"
83
+ :summary-tokens="block.summaryTokens"
84
+ :is-compacting="false"
85
+ :created-at="block.createdAt"
86
+ />
87
+ </div>
88
+ </template>
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ import { computed, toRef } from 'vue'
3
+ import { refThrottled } from '@vueuse/core'
4
+ import AnsiToHtml from 'ansi-to-html'
5
+ import { useTerminalTheme } from '@demicodes/web-ui/composables/useTerminalTheme'
6
+
7
+ const RENDER_THROTTLE = 80
8
+
9
+ const props = defineProps<{
10
+ content: string
11
+ }>()
12
+
13
+ const throttledContent = refThrottled(toRef(props, 'content'), RENDER_THROTTLE)
14
+
15
+ const { terminalTheme } = useTerminalTheme()
16
+
17
+ const ansiConverter = computed(() => {
18
+ const t = terminalTheme.value
19
+ return new AnsiToHtml({
20
+ fg: t.foreground,
21
+ bg: t.background,
22
+ escapeXML: true,
23
+ colors: {
24
+ 0: t.black,
25
+ 1: t.red,
26
+ 2: t.green,
27
+ 3: t.yellow,
28
+ 4: t.blue,
29
+ 5: t.magenta,
30
+ 6: t.cyan,
31
+ 7: t.white,
32
+ 8: t.brightBlack,
33
+ 9: t.brightRed,
34
+ 10: t.brightGreen,
35
+ 11: t.brightYellow,
36
+ 12: t.brightBlue,
37
+ 13: t.brightMagenta,
38
+ 14: t.brightCyan,
39
+ 15: t.brightWhite,
40
+ } as Record<number, string>,
41
+ })
42
+ })
43
+
44
+ function normalizeTerminalContent(content: string): string {
45
+ if (!content.includes('\r')) return content
46
+
47
+ const lines: string[] = []
48
+ let currentLine = ''
49
+
50
+ for (const char of content) {
51
+ if (char === '\r') {
52
+ currentLine = ''
53
+ continue
54
+ }
55
+ if (char === '\n') {
56
+ lines.push(currentLine)
57
+ currentLine = ''
58
+ continue
59
+ }
60
+ currentLine += char
61
+ }
62
+
63
+ lines.push(currentLine)
64
+ return lines.join('\n')
65
+ }
66
+
67
+ const renderedHtml = computed(() => {
68
+ if (!throttledContent.value) return ''
69
+ return ansiConverter.value.toHtml(normalizeTerminalContent(throttledContent.value))
70
+ })
71
+ </script>
72
+
73
+ <template>
74
+ <pre class="font-mono text-[13px] leading-[1.4] text-fg-body whitespace-pre-wrap break-words" v-html="renderedHtml" />
75
+ </template>
@@ -0,0 +1,34 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { md } from '@demicodes/web-ui/markdown/md'
4
+ import { isHttpUrl } from '@demicodes/web-ui/markdown/filePath'
5
+
6
+ const props = defineProps<{
7
+ content: string
8
+ }>()
9
+
10
+ const renderedMarkdown = computed(() => md.render(props.content))
11
+
12
+ function handleClick(event: MouseEvent) {
13
+ const target = (event.target as HTMLElement).closest('a')
14
+ if (!target) return
15
+
16
+ const href = target.getAttribute('href')
17
+ if (!href) return
18
+
19
+ if (isHttpUrl(href)) {
20
+ event.preventDefault()
21
+ window.open(href, '_blank', 'noopener,noreferrer')
22
+ }
23
+ }
24
+ </script>
25
+
26
+ <template>
27
+ <div class="px-[var(--agent-pad-x,2rem)] py-1.5">
28
+ <div
29
+ class="markdown-body select-text text-sm leading-relaxed text-fg-body"
30
+ v-html="renderedMarkdown"
31
+ @click="handleClick"
32
+ />
33
+ </div>
34
+ </template>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps<{
5
+ summary: string
6
+ summaryTokens: number
7
+ isCompacting: boolean
8
+ createdAt: string
9
+ }>()
10
+
11
+ function formatTokens(tokens: number): string {
12
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
13
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
14
+ return String(tokens)
15
+ }
16
+
17
+ const label = computed(() => {
18
+ if (props.isCompacting) return 'Compacting context...'
19
+ return `Context compacted to ~${formatTokens(props.summaryTokens)} tokens`
20
+ })
21
+ </script>
22
+
23
+ <template>
24
+ <div class="flex items-center gap-3 px-[var(--agent-pad-x,2rem)]">
25
+ <div class="h-px flex-1 bg-overlay/6" />
26
+ <span
27
+ v-if="isCompacting"
28
+ class="thinking-shimmer text-[11px] font-medium tracking-wide text-fg-subtle"
29
+ >{{ label }}</span>
30
+ <span
31
+ v-else
32
+ class="text-[11px] font-medium tracking-wide text-fg-subtle"
33
+ >{{ label }}</span>
34
+ <div class="h-px flex-1 bg-overlay/6" />
35
+ </div>
36
+ </template>
@@ -0,0 +1,79 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { useClipboard } from '@vueuse/core'
4
+ import { RightSmallLine } from '@mingcute/vue/right-small'
5
+ import { CopyLine } from '@mingcute/vue/copy'
6
+ import { CheckLine } from '@mingcute/vue/check'
7
+ import { ArrowRightLine } from '@mingcute/vue/arrow-right'
8
+ import { t } from '@demicodes/web-ui/infra/i18n'
9
+
10
+ const props = defineProps<{
11
+ message: string
12
+ code?: string | null
13
+ stack?: string
14
+ }>()
15
+
16
+ const emit = defineEmits<{
17
+ continue: []
18
+ retry: []
19
+ }>()
20
+
21
+ function handleRecovery() {
22
+ emit('continue')
23
+ }
24
+
25
+ const isStackOpen = ref(false)
26
+
27
+ const copyableText = computed(() => {
28
+ const parts = [props.message]
29
+ if (props.code) parts.unshift(`[${props.code}]`)
30
+ if (props.stack) parts.push(props.stack)
31
+ return parts.join('\n')
32
+ })
33
+
34
+ const { copy, copied } = useClipboard({ copiedDuring: 1500 })
35
+ </script>
36
+
37
+ <template>
38
+ <div class="overflow-hidden rounded-lg border border-on-danger-muted bg-tint-danger">
39
+ <div class="flex items-center gap-2 px-3 py-2">
40
+ <span class="text-[12px] font-medium uppercase tracking-wide text-on-danger">{{ t('agent.block.error') }}</span>
41
+ <span
42
+ v-if="props.code"
43
+ class="rounded bg-tint-danger-strong px-1.5 py-0.5 font-mono text-[11px] text-on-danger-muted"
44
+ >{{ props.code }}</span>
45
+ <div class="flex-1" />
46
+ <div
47
+ class="flex cursor-pointer items-center rounded-md p-1 text-on-danger-muted transition-colors hover:bg-tint-danger hover:text-on-danger-muted"
48
+ @click="copy(copyableText)"
49
+ >
50
+ <CheckLine v-if="copied" :size="13" class="text-on-success" />
51
+ <CopyLine v-else :size="13" />
52
+ </div>
53
+ </div>
54
+ <div class="border-t border-on-danger-muted px-3 py-2">
55
+ <pre class="whitespace-pre-wrap break-words font-mono text-[12px] leading-5 text-on-danger">{{ props.message }}</pre>
56
+ </div>
57
+ <div v-if="props.stack">
58
+ <div
59
+ class="flex cursor-pointer items-center gap-1 border-t border-on-danger-muted px-3 py-1.5 text-[11px] text-on-danger-muted transition-colors hover:bg-tint-danger"
60
+ @click="isStackOpen = !isStackOpen"
61
+ >
62
+ <RightSmallLine :size="14" class="transition-transform duration-150" :class="isStackOpen ? 'rotate-90' : ''" />
63
+ <span class="uppercase tracking-wide">{{ t('agent.block.stackTrace') }}</span>
64
+ </div>
65
+ <div v-if="isStackOpen" class="border-t border-on-danger-muted px-3 py-2">
66
+ <pre class="whitespace-pre-wrap break-words font-mono text-[11px] leading-4.5 text-on-danger-muted">{{ props.stack }}</pre>
67
+ </div>
68
+ </div>
69
+ <div class="flex items-center justify-end border-t border-on-danger-muted px-3 py-2">
70
+ <div
71
+ class="flex cursor-pointer items-center gap-1.5 rounded-md bg-tint-danger-strong px-2.5 py-1 text-[12px] font-medium text-on-danger transition-colors hover:bg-tint-danger-strong"
72
+ @click="handleRecovery"
73
+ >
74
+ <ArrowRightLine :size="13" />
75
+ {{ t('common.continue') }}
76
+ </div>
77
+ </div>
78
+ </div>
79
+ </template>
@@ -0,0 +1,118 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onUpdated, ref, useSlots, watch } from 'vue'
3
+ import { RightLine } from '@mingcute/vue/right'
4
+ import ToolStatusBadge from './ToolStatusBadge.vue'
5
+
6
+ const ACTIVE_OUTPUT_CLOSE_DELAY_MS = 1000
7
+
8
+ const props = defineProps<{
9
+ label?: string
10
+ detail?: string
11
+ suffix?: string
12
+ trailing?: string
13
+ loading?: boolean
14
+ error?: boolean
15
+ errorText?: string
16
+ /** Keep the body scrolled to the latest line while content streams in (e.g. live thinking). */
17
+ stickBottom?: boolean
18
+ /** Keep the block open while active output is being produced. */
19
+ openWhile?: boolean
20
+ /** Force expandability instead of inferring it from the body slot (slot presence isn't reactive). */
21
+ expandable?: boolean
22
+ }>()
23
+
24
+ const slots = useSlots()
25
+ const isOpen = defineModel<boolean>('open', { default: false })
26
+ const hasBodySlot = () => !!slots['body']
27
+ const isExpandable = computed(() => props.expandable || hasBodySlot() || !!props.errorText)
28
+ const bodyScroll = ref<HTMLElement>()
29
+ let closeTimer: ReturnType<typeof setTimeout> | undefined
30
+
31
+ function clearCloseTimer() {
32
+ if (!closeTimer) return
33
+ clearTimeout(closeTimer)
34
+ closeTimer = undefined
35
+ }
36
+
37
+ function closeAfterActiveOutputSettles() {
38
+ if (!isOpen.value) return
39
+ clearCloseTimer()
40
+ closeTimer = setTimeout(() => {
41
+ closeTimer = undefined
42
+ if (!props.openWhile && !props.errorText) isOpen.value = false
43
+ }, ACTIVE_OUTPUT_CLOSE_DELAY_MS)
44
+ }
45
+
46
+ watch(
47
+ [() => props.errorText, () => props.openWhile, isExpandable],
48
+ ([errorText, openWhile, expandable]) => {
49
+ clearCloseTimer()
50
+ if (errorText) {
51
+ isOpen.value = true
52
+ return
53
+ }
54
+ if (openWhile === undefined) return
55
+ if (openWhile && expandable) {
56
+ isOpen.value = true
57
+ return
58
+ }
59
+ closeAfterActiveOutputSettles()
60
+ },
61
+ { immediate: true },
62
+ )
63
+
64
+ onBeforeUnmount(clearCloseTimer)
65
+
66
+ onUpdated(() => {
67
+ if (props.stickBottom && isOpen.value && bodyScroll.value) {
68
+ bodyScroll.value.scrollTop = bodyScroll.value.scrollHeight
69
+ }
70
+ })
71
+ </script>
72
+
73
+ <template>
74
+ <div class="overflow-hidden">
75
+ <div
76
+ class="flex select-none items-center gap-2 py-1 text-[13px] text-fg-muted"
77
+ @click="isExpandable && (isOpen = !isOpen)"
78
+ >
79
+ <div class="flex size-4 shrink-0 items-center justify-center">
80
+ <slot name="icon" />
81
+ </div>
82
+ <div class="flex min-w-0 items-center gap-2 overflow-hidden">
83
+ <span v-if="label" class="shrink-0" :class="loading ? 'thinking-shimmer' : ''">{{ label }}</span>
84
+ <slot v-if="slots['default']" :loading="loading" />
85
+ <span v-else-if="detail" class="min-w-0 truncate font-mono text-fg-body" :class="loading ? 'thinking-shimmer' : ''">{{ detail }}</span>
86
+ <span v-if="suffix" class="shrink-0 text-fg-subtle" :class="loading ? 'thinking-shimmer' : ''">{{ suffix }}</span>
87
+ </div>
88
+ <span class="-ml-1 shrink-0 text-xs">
89
+ <ToolStatusBadge v-if="error" status="error" />
90
+ <RightLine v-else-if="isExpandable" :size="14" class="text-fg-faint transition-transform duration-200" :class="isOpen ? 'rotate-90' : ''" />
91
+ <span v-else-if="!loading && trailing" class="text-fg-subtle">{{ trailing }}</span>
92
+ </span>
93
+ <div class="flex-1"></div>
94
+ </div>
95
+ <div v-if="isExpandable" class="functional-block-body" :class="isOpen ? 'is-open' : ''">
96
+ <div class="overflow-hidden">
97
+ <div class="mb-1 overflow-hidden rounded-md bg-overlay/6">
98
+ <div ref="bodyScroll" class="max-h-80 overflow-y-auto py-0.5">
99
+ <pre v-if="errorText" class="whitespace-pre-wrap px-3 py-1.5 font-mono text-xs text-on-danger-muted">{{ errorText }}</pre>
100
+ <slot v-if="hasBodySlot()" name="body" />
101
+ </div>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ </div>
106
+ </template>
107
+
108
+ <style scoped>
109
+ .functional-block-body {
110
+ display: grid;
111
+ grid-template-rows: 0fr;
112
+ transition: grid-template-rows 0.2s ease;
113
+ }
114
+
115
+ .functional-block-body.is-open {
116
+ grid-template-rows: 1fr;
117
+ }
118
+ </style>
@@ -0,0 +1,28 @@
1
+ <template>
2
+ <div class="flex items-center px-[var(--agent-pad-x,2rem)] py-2">
3
+ <div class="loading-dots flex gap-1">
4
+ <span class="size-1.5 rounded-full bg-fg-subtle" />
5
+ <span class="size-1.5 rounded-full bg-fg-subtle" />
6
+ <span class="size-1.5 rounded-full bg-fg-subtle" />
7
+ </div>
8
+ </div>
9
+ </template>
10
+
11
+ <style scoped>
12
+ .loading-dots span {
13
+ animation: dot-pulse 1.4s ease-in-out infinite;
14
+ }
15
+
16
+ .loading-dots span:nth-child(2) {
17
+ animation-delay: 0.2s;
18
+ }
19
+
20
+ .loading-dots span:nth-child(3) {
21
+ animation-delay: 0.4s;
22
+ }
23
+
24
+ @keyframes dot-pulse {
25
+ 0%, 80%, 100% { opacity: 0.3; }
26
+ 40% { opacity: 1; }
27
+ }
28
+ </style>
@@ -0,0 +1,86 @@
1
+ <script setup lang="ts">
2
+ import { computed, onUnmounted, ref, watch } from 'vue'
3
+ import { BrainLine } from '@mingcute/vue/brain'
4
+ import { md } from '@demicodes/web-ui/markdown/md'
5
+ import { t } from '@demicodes/web-ui/infra/i18n'
6
+ import FunctionalBlock from './FunctionalBlock.vue'
7
+
8
+ const props = defineProps<{
9
+ thinking: string
10
+ isStreaming: boolean
11
+ createdAt: string
12
+ /** Start of the block after this one — the moment thinking ended. Null while still thinking. */
13
+ endedAt?: string | null
14
+ }>()
15
+
16
+ const hasContent = computed(() => props.thinking.trim().length > 0)
17
+ const isOpen = ref(false)
18
+ const renderedMarkdown = computed(() => md.render(props.thinking))
19
+
20
+ // Live timer while thinking; once done the elapsed is frozen to (next block's createdAt - this
21
+ // block's createdAt), so the duration survives reload instead of growing from the original time.
22
+ const nowMs = ref(Date.now())
23
+ let timer: ReturnType<typeof setInterval> | undefined
24
+ function stopTimer() {
25
+ if (timer) {
26
+ clearInterval(timer)
27
+ timer = undefined
28
+ }
29
+ }
30
+ watch(
31
+ () => props.isStreaming,
32
+ (streaming) => {
33
+ stopTimer()
34
+ if (streaming) {
35
+ nowMs.value = Date.now()
36
+ timer = setInterval(() => {
37
+ nowMs.value = Date.now()
38
+ }, 1000)
39
+ }
40
+ },
41
+ { immediate: true },
42
+ )
43
+ onUnmounted(stopTimer)
44
+
45
+ const startMs = computed(() => Date.parse(props.createdAt))
46
+ const elapsedMs = computed(() => {
47
+ const end = props.endedAt ? Date.parse(props.endedAt) : props.isStreaming ? nowMs.value : null
48
+ if (end === null || Number.isNaN(startMs.value)) return null
49
+ return Math.max(0, end - startMs.value)
50
+ })
51
+ const label = computed(() => {
52
+ if (elapsedMs.value === null) return t('agent.block.thinking')
53
+ const prefix = t(props.isStreaming ? 'agent.block.thinkingFor' : 'agent.block.thoughtFor')
54
+ return `${prefix} ${formatDuration(elapsedMs.value)}`
55
+ })
56
+
57
+ function formatDuration(ms: number): string {
58
+ const s = Math.round(ms / 1000)
59
+ if (s < 60) return `${s}s`
60
+ const m = Math.floor(s / 60)
61
+ const rs = s % 60
62
+ if (m < 60) return rs ? `${m}m${rs}s` : `${m}m`
63
+ const h = Math.floor(m / 60)
64
+ const rm = m % 60
65
+ return rm ? `${h}h${rm}m` : `${h}h`
66
+ }
67
+ </script>
68
+
69
+ <template>
70
+ <div class="px-[var(--agent-pad-x,2rem)]">
71
+ <FunctionalBlock
72
+ v-model:open="isOpen"
73
+ :expandable="hasContent"
74
+ :open-while="isStreaming && hasContent"
75
+ :stick-bottom="isStreaming"
76
+ >
77
+ <template #icon>
78
+ <BrainLine :size="16" />
79
+ </template>
80
+ <span class="min-w-0 truncate" :class="isStreaming ? 'thinking-shimmer' : ''">{{ label }}</span>
81
+ <template v-if="hasContent" #body>
82
+ <div class="markdown-body px-3 py-1 text-[13px] leading-relaxed text-fg-muted" v-html="renderedMarkdown" />
83
+ </template>
84
+ </FunctionalBlock>
85
+ </div>
86
+ </template>
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { parse, Allow } from 'partial-json'
4
+ import type { ToolCallBlock } from '../block-types'
5
+ import ToolShellBlock from './ToolShellBlock.vue'
6
+ import ToolShellStatusBlock from './ToolShellStatusBlock.vue'
7
+ import ToolShellWriteBlock from './ToolShellWriteBlock.vue'
8
+ import ToolShellAbortBlock from './ToolShellAbortBlock.vue'
9
+ import ToolYieldBlock from './ToolYieldBlock.vue'
10
+ import ToolGenericBlock from './ToolGenericBlock.vue'
11
+ import { shouldParsePartialToolInput, toolRenderKind } from '../tool-rendering'
12
+
13
+ const props = defineProps<{
14
+ block: ToolCallBlock
15
+ conversationId: string
16
+ isStreaming: boolean
17
+ }>()
18
+
19
+ const parsedInput = computed<Record<string, unknown>>(() => {
20
+ if (!props.block.input) return {}
21
+ try {
22
+ const result = shouldParsePartialToolInput(props.block.toolName)
23
+ ? parse(props.block.input, Allow.ALL)
24
+ : JSON.parse(props.block.input)
25
+ return typeof result === 'object' && result !== null ? result as Record<string, unknown> : {}
26
+ } catch {
27
+ return {}
28
+ }
29
+ })
30
+ const renderKind = computed(() => toolRenderKind(props.block.toolName))
31
+ </script>
32
+
33
+ <template>
34
+ <ToolShellBlock v-if="renderKind === 'shell_exec'" :block="block" :input="parsedInput" :is-streaming="isStreaming" />
35
+ <ToolShellStatusBlock v-else-if="renderKind === 'shell_status'" :block="block" :input="parsedInput" />
36
+ <ToolShellWriteBlock v-else-if="renderKind === 'shell_write'" :block="block" :input="parsedInput" />
37
+ <ToolShellAbortBlock v-else-if="renderKind === 'shell_abort'" :block="block" :input="parsedInput" />
38
+ <ToolYieldBlock v-else-if="renderKind === 'yield'" :block="block" :input="parsedInput" />
39
+ <ToolGenericBlock v-else :block="block" :input="parsedInput" />
40
+ </template>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { FlashLine } from '@mingcute/vue/flash'
4
+ import FunctionalBlock from './FunctionalBlock.vue'
5
+ import type { ToolCallBlock } from '../block-types'
6
+ import { getToolErrorText } from '../block-helpers'
7
+ import { trimToolSummary } from '../tool-rendering'
8
+
9
+ const props = defineProps<{
10
+ block: ToolCallBlock
11
+ input: Record<string, unknown>
12
+ }>()
13
+
14
+ const summary = computed(() => {
15
+ const entries = Object.entries(props.input)
16
+ if (entries.length === 0) return ''
17
+ return entries
18
+ .map(([k, v]) => {
19
+ const val = typeof v === 'string' ? v : JSON.stringify(v)
20
+ const short = val.length > 40 ? `${val.slice(0, 37)}...` : val
21
+ return `${k}=${short}`
22
+ })
23
+ .join(' ')
24
+ })
25
+ const errorText = computed(() => getToolErrorText(props.block))
26
+ const detail = computed(() => {
27
+ const text = errorText.value
28
+ return text ? trimToolSummary(text, 160) : summary.value
29
+ })
30
+ </script>
31
+
32
+ <template>
33
+ <FunctionalBlock
34
+ :label="block.toolName"
35
+ :detail="detail"
36
+ :loading="block.status === 'executing'"
37
+ :error="block.status === 'error'"
38
+ >
39
+ <template #icon>
40
+ <FlashLine :size="16" />
41
+ </template>
42
+ </FunctionalBlock>
43
+ </template>
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ import type { ToolCallBlock } from '../block-types'
3
+ import ToolShellControlBlock from './ToolShellControlBlock.vue'
4
+
5
+ defineProps<{
6
+ block: ToolCallBlock
7
+ input: Record<string, unknown>
8
+ }>()
9
+ </script>
10
+
11
+ <template>
12
+ <ToolShellControlBlock :block="block" :input="input" tool-name="shell_abort" />
13
+ </template>