@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,71 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import dayjs from 'dayjs'
4
+ import relativeTime from 'dayjs/plugin/relativeTime'
5
+ import { HistoryLine } from '@mingcute/vue/history'
6
+ import { Chat1Line } from '@mingcute/vue/chat-1'
7
+
8
+ dayjs.extend(relativeTime)
9
+ import { t } from '@demicodes/web-ui/infra/i18n'
10
+ import { appOverlayStore } from '@demicodes/web-ui/overlay/appOverlay'
11
+ import HighlightText from '@demicodes/web-ui/ui/HighlightText.vue'
12
+ import SelectDropdown from '@demicodes/web-ui/ui/SelectDropdown.vue'
13
+
14
+ // Open tabs and server-side history summaries both satisfy this shape.
15
+ interface ConversationListItem {
16
+ id: string
17
+ title: string
18
+ createdAt: string
19
+ }
20
+
21
+ const props = defineProps<{
22
+ conversations: ConversationListItem[]
23
+ activeTabId: string | null
24
+ }>()
25
+
26
+ const emit = defineEmits<{
27
+ select: [conversationId: string]
28
+ }>()
29
+
30
+ const items = computed(() =>
31
+ props.conversations.map((c) => ({ id: c.id, label: c.title, createdAt: c.createdAt })),
32
+ )
33
+
34
+ function formatTime(iso: string): string {
35
+ return dayjs(iso).fromNow()
36
+ }
37
+ </script>
38
+
39
+ <template>
40
+ <SelectDropdown
41
+ :overlay-store="appOverlayStore"
42
+ :items="items"
43
+ :selected-id="activeTabId ?? undefined"
44
+ searchable
45
+ :search-placeholder="t('agent.conversationList.placeholder')"
46
+ :empty-text="t('agent.conversationList.empty')"
47
+ panel-class="w-96"
48
+ :item-height="32"
49
+ placement="bottom-end"
50
+ :offset="6"
51
+ @select="emit('select', $event)"
52
+ >
53
+ <template #trigger="{ isOpen }">
54
+ <span
55
+ class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-fg-faint transition-colors hover:bg-hover hover:text-fg-muted"
56
+ :class="isOpen && 'bg-overlay/6 text-fg-muted'"
57
+ >
58
+ <HistoryLine :size="14" />
59
+ </span>
60
+ </template>
61
+ <template #item="{ item, query }">
62
+ <Chat1Line :size="13" class="shrink-0" />
63
+ <span class="flex min-w-0 flex-1 items-baseline gap-1.5">
64
+ <span class="truncate text-[13px]">
65
+ <HighlightText :text="item.label" :query="query" />
66
+ </span>
67
+ <span class="shrink-0 text-[11px] text-fg-faint">{{ formatTime(item.createdAt) }}</span>
68
+ </span>
69
+ </template>
70
+ </SelectDropdown>
71
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { ConversationStatus } from './conversation-status'
4
+
5
+ const props = defineProps<{
6
+ status: ConversationStatus
7
+ }>()
8
+
9
+ const dotColor = computed(() => {
10
+ if (props.status === 'active') return 'var(--color-blue-400)'
11
+ if (props.status === 'error' || props.status === 'aborted') return 'var(--color-red-400)'
12
+ if (props.status === 'done') return 'var(--color-emerald-400)'
13
+ return null
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <span
19
+ class="absolute -right-px -top-px size-1.5 rounded-full border transition-[background-color,opacity] duration-300"
20
+ :class="[
21
+ dotColor ? 'opacity-100 border-surface-base' : 'opacity-0 border-transparent',
22
+ status === 'active' && 'animate-pulse',
23
+ ]"
24
+ :style="{ backgroundColor: dotColor ?? 'transparent' }"
25
+ />
26
+ </template>
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { useElementSize } from '@vueuse/core'
4
+ import { useAgentWorkspace } from './workspace'
5
+ import AgentMessageList from './AgentMessageList.vue'
6
+ import AgentMessageInput from './AgentMessageInput.vue'
7
+ import MessageQueueBar from './MessageQueueBar.vue'
8
+ import { queuedMessageIdForEmptySubmit } from './queue-submit'
9
+ import { reportError } from '@demicodes/web-ui/infra/errors'
10
+
11
+ const props = defineProps<{
12
+ conversationId: string
13
+ }>()
14
+
15
+ const workspace = useAgentWorkspace()
16
+ const session = computed(() => workspace.sessions[props.conversationId])
17
+ const blocks = computed(() => session.value?.blocks ?? [])
18
+ const queuedMessages = computed(() => session.value?.queue ?? [])
19
+ const pendingSteers = computed(() => session.value?.pendingSteers ?? [])
20
+ const phase = computed(() => session.value?.phase ?? 'idle')
21
+
22
+ const bottomAreaRef = ref<HTMLDivElement>()
23
+ const messageInputRef = ref<InstanceType<typeof AgentMessageInput>>()
24
+ const { height: bottomAreaHeight } = useElementSize(bottomAreaRef)
25
+
26
+ function handleEmptySubmit() {
27
+ const messageId = queuedMessageIdForEmptySubmit(queuedMessages.value)
28
+ if (!messageId) return
29
+ handleQueuedSendNow(messageId)
30
+ }
31
+
32
+ function handleQueuedSendNow(messageId: string) {
33
+ if (phase.value === 'running') {
34
+ void workspace.steerQueuedMessage(props.conversationId, messageId).catch((error) => {
35
+ reportError('Failed to steer queued message', error, { userVisible: true })
36
+ })
37
+ return
38
+ }
39
+ workspace.sendQueuedMessage(props.conversationId, messageId)
40
+ }
41
+
42
+ function onContinue() {
43
+ void workspace.resume(props.conversationId)
44
+ }
45
+
46
+ function onRetry() {
47
+ void workspace.retry(props.conversationId)
48
+ }
49
+ </script>
50
+
51
+ <template>
52
+ <div class="relative h-full flex-1 overflow-hidden bg-surface">
53
+ <AgentMessageList
54
+ :conversation-id="conversationId"
55
+ :blocks="blocks"
56
+ :pending-steers="pendingSteers"
57
+ :phase="phase"
58
+ :bottom-offset="bottomAreaHeight"
59
+ :persisted-scroll-state="undefined"
60
+ @continue="onContinue"
61
+ @retry="onRetry"
62
+ @delete-pending-steer="(steerId) => workspace.deletePendingSteer(conversationId, steerId)"
63
+ />
64
+ <div class="absolute bottom-0 left-0 right-0 z-10 px-3 pb-3">
65
+ <div ref="bottomAreaRef" class="relative">
66
+ <MessageQueueBar
67
+ v-if="queuedMessages.length"
68
+ :messages="queuedMessages"
69
+ @remove="(messageId) => workspace.dequeueMessage(conversationId, messageId)"
70
+ @send-now="handleQueuedSendNow"
71
+ @clear-all="workspace.clearMessageQueue(conversationId)"
72
+ />
73
+ <AgentMessageInput
74
+ ref="messageInputRef"
75
+ class="relative z-10"
76
+ :conversation-id="conversationId"
77
+ @empty-submit="handleEmptySubmit"
78
+ />
79
+ </div>
80
+ </div>
81
+ </div>
82
+ </template>
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+ import { DeleteLine } from '@mingcute/vue/delete'
3
+ import { ListCheckLine } from '@mingcute/vue/list-check'
4
+ import { SendLine } from '@mingcute/vue/send'
5
+
6
+ defineProps<{
7
+ messages: { id: string; text: string }[]
8
+ }>()
9
+
10
+ const emit = defineEmits<{
11
+ remove: [id: string]
12
+ sendNow: [id: string]
13
+ clearAll: []
14
+ }>()
15
+ </script>
16
+
17
+ <template>
18
+ <div class="relative mx-3 -mb-3">
19
+ <div class="rounded-t-lg bg-surface-raised border border-line-subtle border-b-0 pb-4.5">
20
+ <div class="flex flex-col px-1 pt-1.5">
21
+ <div class="flex items-center gap-2 px-1.5 pb-1">
22
+ <div class="flex flex-1 items-center gap-1.5 text-[12px] text-fg-subtle">
23
+ <ListCheckLine :size="14" />
24
+ <span>{{ messages.length }} Queued</span>
25
+ </div>
26
+ <div class="shrink-0 flex items-center gap-0.5">
27
+ <span
28
+ class="flex size-6 cursor-pointer items-center justify-center rounded-md text-fg-subtle transition-colors hover:bg-active hover:text-fg-body"
29
+ @click="emit('clearAll')"
30
+ >
31
+ <DeleteLine :size="14" />
32
+ </span>
33
+ </div>
34
+ </div>
35
+ <div class="flex flex-col">
36
+ <div
37
+ v-for="(message, index) in messages"
38
+ :key="message.id"
39
+ class="flex items-center gap-2 px-1.5"
40
+ >
41
+ <span class="shrink-0 text-[12px] tabular-nums text-fg-faint">{{ index + 1 }}</span>
42
+ <div
43
+ class="flex min-w-0 flex-1 items-center gap-2 py-1"
44
+ :class="index < messages.length - 1 ? 'border-b border-line-subtle' : ''"
45
+ >
46
+ <span class="min-w-0 flex-1 truncate text-[13px] text-fg-muted">{{ message.text }}</span>
47
+ <div class="shrink-0 flex items-center gap-0.5">
48
+ <span
49
+ class="flex size-6 cursor-pointer items-center justify-center rounded-md text-fg-subtle transition-colors hover:bg-active hover:text-fg-body"
50
+ @click="emit('sendNow', message.id)"
51
+ >
52
+ <SendLine :size="14" />
53
+ </span>
54
+ <span
55
+ class="flex size-6 cursor-pointer items-center justify-center rounded-md text-fg-subtle transition-colors hover:bg-active hover:text-fg-body"
56
+ @click="emit('remove', message.id)"
57
+ >
58
+ <DeleteLine :size="14" />
59
+ </span>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </template>
@@ -0,0 +1,125 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import type { ThinkingConfig } from '@demicodes/core'
4
+ import type { ModelInfo, ProviderInfo } from '../transport/protocol'
5
+ import { appOverlayStore } from '@demicodes/web-ui/overlay/appOverlay'
6
+ import { buildReasoningState } from './reasoning'
7
+ import DropdownMenu from '@demicodes/web-ui/ui/DropdownMenu.vue'
8
+ import SelectorTrigger from './SelectorTrigger.vue'
9
+ import ReasoningSelector from './ReasoningSelector.vue'
10
+ import OptionMenu from '@demicodes/web-ui/ui/OptionMenu.vue'
11
+ import OptionMenuGroup from '@demicodes/web-ui/ui/OptionMenuGroup.vue'
12
+ import OptionMenuItem from '@demicodes/web-ui/ui/OptionMenuItem.vue'
13
+
14
+ const props = defineProps<{
15
+ providers: ProviderInfo[]
16
+ models: Record<string, ModelInfo[]>
17
+ selectedProviderId?: string | null
18
+ selectedModelId?: string | null
19
+ thinkingConfig?: ThinkingConfig
20
+ }>()
21
+
22
+ const emit = defineEmits<{
23
+ selectModel: [providerId: string, modelId: string]
24
+ changeThinking: [config: ThinkingConfig]
25
+ }>()
26
+
27
+ interface SelectedModel {
28
+ providerId: string
29
+ modelId: string
30
+ }
31
+
32
+ const selectedModel = ref<SelectedModel | null>(null)
33
+
34
+ const providerModels = computed(() => props.models)
35
+
36
+ const availableProviders = computed(() =>
37
+ props.providers.filter(p => p.isAvailable && (providerModels.value[p.id]?.length ?? 0) > 0),
38
+ )
39
+
40
+ const hasModels = computed(() =>
41
+ availableProviders.value.some(p => (providerModels.value[p.id] ?? []).length > 0),
42
+ )
43
+
44
+ const selectedModelPreset = computed(() => {
45
+ const selected = selectedModel.value
46
+ if (!selected) return null
47
+ return (providerModels.value[selected.providerId] ?? []).find(m => m.id === selected.modelId) ?? null
48
+ })
49
+
50
+ const selectedModelLabel = computed(() => selectedModelPreset.value?.name ?? '')
51
+
52
+ const reasoningState = computed(() => buildReasoningState(selectedModelPreset.value))
53
+
54
+ function isSelectedModel(providerId: string, modelId: string): boolean {
55
+ return selectedModel.value?.providerId === providerId
56
+ && selectedModel.value?.modelId === modelId
57
+ }
58
+
59
+ function selectModel(providerId: string, modelId: string) {
60
+ selectedModel.value = { providerId, modelId }
61
+ emit('selectModel', providerId, modelId)
62
+ }
63
+
64
+ function resolveFallbackSelection(): SelectedModel | null {
65
+ for (const provider of availableProviders.value) {
66
+ const firstModel = providerModels.value[provider.id]?.[0]
67
+ if (firstModel) return { providerId: provider.id, modelId: firstModel.id }
68
+ }
69
+ return null
70
+ }
71
+
72
+ watch(
73
+ [
74
+ () => props.selectedProviderId,
75
+ () => props.selectedModelId,
76
+ providerModels,
77
+ availableProviders,
78
+ ],
79
+ () => {
80
+ if (props.selectedProviderId && props.selectedModelId) {
81
+ selectedModel.value = { providerId: props.selectedProviderId, modelId: props.selectedModelId }
82
+ return
83
+ }
84
+ selectedModel.value = resolveFallbackSelection()
85
+ },
86
+ { immediate: true, deep: true },
87
+ )
88
+ </script>
89
+
90
+ <template>
91
+ <template v-if="hasModels">
92
+ <DropdownMenu :overlay-store="appOverlayStore">
93
+ <template #trigger="{ isOpen }">
94
+ <SelectorTrigger :is-open="isOpen">
95
+ {{ selectedModelLabel }}
96
+ </SelectorTrigger>
97
+ </template>
98
+ <template #content="{ close }">
99
+ <OptionMenu>
100
+ <OptionMenuGroup
101
+ v-for="provider in availableProviders"
102
+ :key="provider.id"
103
+ :label="provider.label"
104
+ >
105
+ <OptionMenuItem
106
+ v-for="model in providerModels[provider.id] ?? []"
107
+ :key="`${provider.id}:${model.id}`"
108
+ :label="model.name"
109
+ :is-selected="isSelectedModel(provider.id, model.id)"
110
+ @select="selectModel(provider.id, model.id); close()"
111
+ />
112
+ </OptionMenuGroup>
113
+ </OptionMenu>
114
+ </template>
115
+ </DropdownMenu>
116
+ <template v-if="reasoningState">
117
+ <span class="h-3 w-px bg-overlay/10" />
118
+ <ReasoningSelector
119
+ :reasoning-state="reasoningState"
120
+ v-bind="thinkingConfig ? { config: thinkingConfig } : {}"
121
+ @change="emit('changeThinking', $event)"
122
+ />
123
+ </template>
124
+ </template>
125
+ </template>
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ import { computed, watchEffect } from 'vue'
3
+ import type { ThinkingConfig } from '@demicodes/core'
4
+ import type { ReasoningState } from './reasoning'
5
+ import { BrainLine } from '@mingcute/vue/brain'
6
+ import { appOverlayStore } from '@demicodes/web-ui/overlay/appOverlay'
7
+ import { t } from '@demicodes/web-ui/infra/i18n'
8
+ import Switch from '@demicodes/web-ui/ui/Switch.vue'
9
+ import DropdownMenu from '@demicodes/web-ui/ui/DropdownMenu.vue'
10
+ import SelectorTrigger from './SelectorTrigger.vue'
11
+ import OptionMenu from '@demicodes/web-ui/ui/OptionMenu.vue'
12
+ import OptionMenuGroup from '@demicodes/web-ui/ui/OptionMenuGroup.vue'
13
+ import OptionMenuItem from '@demicodes/web-ui/ui/OptionMenuItem.vue'
14
+
15
+ const props = defineProps<{
16
+ reasoningState: ReasoningState
17
+ config?: ThinkingConfig
18
+ }>()
19
+
20
+ const emit = defineEmits<{
21
+ change: [config: ThinkingConfig]
22
+ }>()
23
+
24
+ const resolvedConfig = computed(() => {
25
+ const cfg = props.config ?? props.reasoningState.defaultConfig
26
+ // Models that can't disable thinking (e.g. Claude Code) have no "disabled" state — a stale or
27
+ // carried-over disabled config resolves to the default effort so the label and the request match.
28
+ if (!props.reasoningState.canDisable && cfg.type === 'disabled') return props.reasoningState.defaultConfig
29
+ return cfg
30
+ })
31
+
32
+ // Persist that coercion upward so the next request actually sends an effort, not a no-op "disabled".
33
+ watchEffect(() => {
34
+ if (!props.reasoningState.canDisable && props.config?.type === 'disabled') {
35
+ emit('change', props.reasoningState.defaultConfig)
36
+ }
37
+ })
38
+
39
+ const isThinkingEnabled = computed(() => resolvedConfig.value.type !== 'disabled')
40
+
41
+ function toggleThinking(enabled: boolean) {
42
+ emit('change', enabled ? props.reasoningState.defaultConfig : { type: 'disabled' })
43
+ }
44
+
45
+ function isSelected(optionConfig: ThinkingConfig): boolean {
46
+ const cfg = resolvedConfig.value
47
+ if (cfg.type !== optionConfig.type) return false
48
+ if (cfg.type === 'adaptive' && optionConfig.type === 'adaptive') return cfg.effort === optionConfig.effort
49
+ if (cfg.type === 'effort' && optionConfig.type === 'effort') return cfg.effort === optionConfig.effort
50
+ return true
51
+ }
52
+
53
+ const defaultOption = computed(() => props.reasoningState.options.find((o) => isSelected(o.config)))
54
+
55
+ const currentDropdownLabel = computed(() => {
56
+ if (defaultOption.value) return defaultOption.value.label
57
+ const fallback = props.reasoningState.options.find((o) =>
58
+ o.config.type === props.reasoningState.defaultConfig.type,
59
+ )
60
+ return fallback?.label ?? ''
61
+ })
62
+
63
+ function selectOption(config: ThinkingConfig, close: () => void) {
64
+ emit('change', config)
65
+ close()
66
+ }
67
+ </script>
68
+
69
+ <template>
70
+ <Switch
71
+ v-if="reasoningState.mode === 'toggle'"
72
+ :model-value="isThinkingEnabled"
73
+ :label="t('providers.model.thinking')"
74
+ size="sm"
75
+ @update:model-value="toggleThinking"
76
+ />
77
+
78
+ <DropdownMenu v-else-if="reasoningState.mode === 'dropdown'" :overlay-store="appOverlayStore">
79
+ <template #trigger="{ isOpen }">
80
+ <SelectorTrigger :is-open="isOpen">
81
+ <BrainLine :size="13" class="mr-0.5" />
82
+ {{ currentDropdownLabel }}
83
+ </SelectorTrigger>
84
+ </template>
85
+ <template #content="{ close }">
86
+ <OptionMenu>
87
+ <OptionMenuItem
88
+ v-for="option in reasoningState.options.filter(o => o.group === 'general')"
89
+ :key="option.label"
90
+ :label="option.label"
91
+ :is-selected="isSelected(option.config)"
92
+ @select="selectOption(option.config, close)"
93
+ />
94
+ <OptionMenuGroup :label="t('providers.reasoning')">
95
+ <OptionMenuItem
96
+ v-for="option in reasoningState.options.filter(o => o.group === 'effort')"
97
+ :key="option.label"
98
+ :label="option.label"
99
+ :is-selected="isSelected(option.config)"
100
+ @select="selectOption(option.config, close)"
101
+ />
102
+ </OptionMenuGroup>
103
+ </OptionMenu>
104
+ </template>
105
+ </DropdownMenu>
106
+ </template>
@@ -0,0 +1,17 @@
1
+ <script setup lang="ts">
2
+ import { RightSmallLine } from '@mingcute/vue/right-small'
3
+
4
+ defineProps<{
5
+ isOpen: boolean
6
+ }>()
7
+ </script>
8
+
9
+ <template>
10
+ <span
11
+ class="flex cursor-pointer items-center gap-0.5 rounded-md pl-2 pr-0.5 py-1 text-[12px] transition-colors"
12
+ :class="isOpen ? 'bg-overlay/5 text-fg-muted' : 'text-fg-subtle hover:bg-hover hover:text-fg-muted'"
13
+ >
14
+ <slot />
15
+ <RightSmallLine :size="16" class="rotate-90" />
16
+ </span>
17
+ </template>
@@ -0,0 +1,73 @@
1
+ import { expect, test } from 'bun:test'
2
+ import type { Block, ModelSelection } from '@demicodes/core'
3
+ import { shellTerminalOutputChunks } from '../block-helpers'
4
+
5
+ test('shell terminal output renders the view chunks', () => {
6
+ const block = tool({
7
+ view: {
8
+ kind: 'shell',
9
+ chunks: [
10
+ { stream: 'stdout', text: 'first stdout\n' },
11
+ { stream: 'stderr', text: 'first stderr\n' },
12
+ { stream: 'stdout', text: 'second stdout\n' },
13
+ ],
14
+ },
15
+ })
16
+
17
+ expect(shellTerminalOutputChunks(block)).toEqual([
18
+ { stream: 'stdout', text: 'first stdout\n' },
19
+ { stream: 'stderr', text: 'first stderr\n' },
20
+ { stream: 'stdout', text: 'second stdout\n' },
21
+ ])
22
+ })
23
+
24
+ test('shell terminal output skips empty and malformed chunks', () => {
25
+ const block = tool({
26
+ view: {
27
+ kind: 'shell',
28
+ chunks: [
29
+ { stream: 'stdout', text: '' },
30
+ { stream: 'other', text: 'not a stream' },
31
+ 'garbage',
32
+ { stream: 'stderr', text: 'kept\n' },
33
+ ],
34
+ },
35
+ })
36
+
37
+ expect(shellTerminalOutputChunks(block)).toEqual([{ stream: 'stderr', text: 'kept\n' }])
38
+ })
39
+
40
+ test('shell terminal output is empty without a chunked view', () => {
41
+ expect(shellTerminalOutputChunks(tool({}))).toEqual([])
42
+ expect(shellTerminalOutputChunks(tool({ view: { kind: 'yield_wakeup' } }))).toEqual([])
43
+ })
44
+
45
+ function tool(options: { view?: unknown }): Extract<Block, { type: 'tool_call' }> {
46
+ return {
47
+ type: 'tool_call',
48
+ id: 'tool-1',
49
+ createdAt: '1970-01-01T00:00:00.000Z',
50
+ model,
51
+ toolUseId: 'tool-1',
52
+ toolName: 'shell_exec',
53
+ input: '{}',
54
+ status: 'completed',
55
+ streamingOutput: [],
56
+ output: [{ type: 'text', text: 'status: exited' }],
57
+ view: options.view ?? null,
58
+ }
59
+ }
60
+
61
+ const model: ModelSelection = {
62
+ providerId: 'test',
63
+ model: {
64
+ id: 'test-model',
65
+ name: 'Test Model',
66
+ contextWindow: 1000,
67
+ inputLimit: null,
68
+ thinking: [],
69
+ acceptedExtensions: [],
70
+ },
71
+ thinking: null,
72
+ serviceTierId: null,
73
+ }