@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,130 @@
1
+ import { expect, test } from 'bun:test'
2
+ import type { UserContentBlock } from '@demicodes/core'
3
+ import { useAgentInputActions } from '../message-input/useAgentInputActions'
4
+ import type { AgentWorkspace } from '../workspace'
5
+
6
+ test('input submit sends a new turn while idle', async () => {
7
+ const calls: string[] = []
8
+ const workspace = fakeWorkspace('idle', calls)
9
+ const actions = useAgentInputActions({
10
+ workspace,
11
+ conversationId: 'conversation-1',
12
+ buildSubmitPayload: () => [{ type: 'text', text: 'new turn' }],
13
+ clearInput: () => calls.push('clear'),
14
+ })
15
+
16
+ await actions.handleSubmit()
17
+
18
+ expect(calls).toEqual(['clear', 'send:new turn'])
19
+ })
20
+
21
+ test('input submit queues a new turn while running', async () => {
22
+ const calls: string[] = []
23
+ const workspace = fakeWorkspace('running', calls)
24
+ const actions = useAgentInputActions({
25
+ workspace,
26
+ conversationId: 'conversation-1',
27
+ buildSubmitPayload: () => [{ type: 'text', text: 'run after this' }],
28
+ clearInput: () => calls.push('clear'),
29
+ })
30
+
31
+ await actions.handleSubmit()
32
+
33
+ expect(calls).toEqual(['clear', 'send:run after this'])
34
+ })
35
+
36
+ test('input steer action steers the active turn while running', async () => {
37
+ const calls: string[] = []
38
+ const workspace = fakeWorkspace('running', calls)
39
+ const actions = useAgentInputActions({
40
+ workspace,
41
+ conversationId: 'conversation-1',
42
+ buildSubmitPayload: () => [{ type: 'text', text: 'refine this turn' }],
43
+ clearInput: () => calls.push('clear'),
44
+ })
45
+
46
+ await actions.handleSteerSubmit()
47
+
48
+ expect(calls).toEqual(['clear', 'steer:refine this turn'])
49
+ })
50
+
51
+ test('input queue action sends a new turn while running', async () => {
52
+ const calls: string[] = []
53
+ const workspace = fakeWorkspace('running', calls)
54
+ const actions = useAgentInputActions({
55
+ workspace,
56
+ conversationId: 'conversation-1',
57
+ buildSubmitPayload: () => [{ type: 'text', text: 'run after this' }],
58
+ clearInput: () => calls.push('clear'),
59
+ })
60
+
61
+ await actions.handleQueueSubmit()
62
+
63
+ expect(calls).toEqual(['clear', 'send:run after this'])
64
+ })
65
+
66
+ test('input submit emits empty-submit when there is no payload', async () => {
67
+ const calls: string[] = []
68
+ const workspace = fakeWorkspace('running', calls)
69
+ const actions = useAgentInputActions({
70
+ workspace,
71
+ conversationId: 'conversation-1',
72
+ buildSubmitPayload: () => null,
73
+ clearInput: () => calls.push('clear'),
74
+ emitEmptySubmit: () => calls.push('empty-submit'),
75
+ })
76
+
77
+ await actions.handleSubmit()
78
+
79
+ expect(calls).toEqual(['empty-submit'])
80
+ })
81
+
82
+ test('empty input submit steers the last queued message while running', async () => {
83
+ const calls: string[] = []
84
+ const workspace = fakeWorkspace('running', calls, [
85
+ { id: 'queued-first' },
86
+ { id: 'queued-last' },
87
+ ])
88
+ const actions = useAgentInputActions({
89
+ workspace,
90
+ conversationId: 'conversation-1',
91
+ buildSubmitPayload: () => null,
92
+ clearInput: () => calls.push('clear'),
93
+ emitEmptySubmit: () => calls.push('empty-submit'),
94
+ })
95
+
96
+ await actions.handleSubmit()
97
+
98
+ expect(calls).toEqual(['steer-queued:queued-last'])
99
+ })
100
+
101
+ function fakeWorkspace(
102
+ phase: 'idle' | 'running' | 'compacting',
103
+ calls: string[],
104
+ queue: Array<{ id: string }> = [],
105
+ ): AgentWorkspace {
106
+ return {
107
+ sessions: {
108
+ 'conversation-1': { phase, queue },
109
+ },
110
+ send: async (_id: string, content: UserContentBlock[]) => {
111
+ calls.push(`send:${textContent(content)}`)
112
+ },
113
+ sendQueuedMessage: (_id: string, messageId: string) => {
114
+ calls.push(`send-queued:${messageId}`)
115
+ },
116
+ steerQueuedMessage: async (_id: string, messageId: string) => {
117
+ calls.push(`steer-queued:${messageId}`)
118
+ },
119
+ steer: async (_id: string, content: UserContentBlock[]) => {
120
+ calls.push(`steer:${textContent(content)}`)
121
+ },
122
+ setModel: () => {},
123
+ abort: async () => {},
124
+ compact: async () => {},
125
+ } as unknown as AgentWorkspace
126
+ }
127
+
128
+ function textContent(content: UserContentBlock[]): string {
129
+ return content.map((block) => (block.type === 'text' ? block.text : `[${block.type}]`)).join('\n')
130
+ }
@@ -0,0 +1,15 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { editorHasContent, shouldSubmitFromEditorKeydown } from '../message-input/useAgentInputEditor'
3
+
4
+ test('editor keydown submits only bare Enter', () => {
5
+ expect(shouldSubmitFromEditorKeydown({ isComposing: false, key: 'Enter', shiftKey: false })).toBe(true)
6
+ expect(shouldSubmitFromEditorKeydown({ isComposing: false, key: 'Enter', shiftKey: true })).toBe(false)
7
+ expect(shouldSubmitFromEditorKeydown({ isComposing: true, key: 'Enter', shiftKey: false })).toBe(false)
8
+ expect(shouldSubmitFromEditorKeydown({ isComposing: false, key: 'a', shiftKey: false })).toBe(false)
9
+ })
10
+
11
+ test('editor content state follows editor emptiness', () => {
12
+ expect(editorHasContent({ isEmpty: false })).toBe(true)
13
+ expect(editorHasContent({ isEmpty: true })).toBe(false)
14
+ expect(editorHasContent(null)).toBe(false)
15
+ })
@@ -0,0 +1,99 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { reactive } from 'vue'
3
+ import type { Block, UserContentBlock } from '@demicodes/core'
4
+ import {
5
+ createPendingSteerMessage,
6
+ pendingSteersToRenderBlocks,
7
+ reconcilePendingSteers,
8
+ } from '../pending-steers'
9
+
10
+ test('pending steer renders as a tail block without mutating transcript blocks', () => {
11
+ const content = text('same-turn guidance')
12
+ const pending = createPendingSteerMessage('local-1', content, [])
13
+
14
+ const renderBlocks = pendingSteersToRenderBlocks([pending])
15
+
16
+ expect(renderBlocks).toEqual([
17
+ {
18
+ type: 'pending_steer',
19
+ id: 'pending-steer:local-1',
20
+ pendingSteerId: 'local-1',
21
+ content,
22
+ },
23
+ ])
24
+ })
25
+
26
+ test('pending steer accepts queued content from reactive state', () => {
27
+ const content = reactive(text('queued as steer')) as UserContentBlock[]
28
+ const pending = createPendingSteerMessage('local-1', content, [])
29
+
30
+ expect(pending.content).toEqual(text('queued as steer'))
31
+ expect(pending.content).not.toBe(content)
32
+ })
33
+
34
+ test('pending steer clones and reconciles native video content', () => {
35
+ const data = new Uint8Array([1, 2, 3])
36
+ const content: UserContentBlock[] = [{ type: 'video', source: { type: 'binary', mediaType: 'video/mp4', data } }]
37
+ const pending = createPendingSteerMessage('local-video', content, [])
38
+
39
+ expect(pending.content).toEqual(content)
40
+ expect(pending.content).not.toBe(content)
41
+ const source = content[0]?.type === 'video' ? content[0].source : null
42
+ expect((pending.content[0] as Extract<UserContentBlock, { type: 'video' }>).source).not.toBe(source)
43
+ expect(reconcilePendingSteers([videoSteerBlock('materialized', new Uint8Array([1, 2, 3]))], [pending])).toEqual([])
44
+ })
45
+
46
+ test('reconcile keeps pending steer when only baseline steer blocks exist', () => {
47
+ const baseline = [steerBlock('existing', 'same text')]
48
+ const pending = createPendingSteerMessage('local-1', text('same text'), baseline)
49
+
50
+ expect(reconcilePendingSteers(baseline, [pending])).toEqual([pending])
51
+ })
52
+
53
+ test('reconcile removes pending steer after a matching transcript steer materializes', () => {
54
+ const baseline = [steerBlock('existing', 'old steer')]
55
+ const pending = createPendingSteerMessage('local-1', text('same-turn guidance'), baseline)
56
+ const nextBlocks = [...baseline, steerBlock('materialized', 'same-turn guidance')]
57
+
58
+ expect(reconcilePendingSteers(nextBlocks, [pending])).toEqual([])
59
+ })
60
+
61
+ test('reconcile removes pending steer when reference resolution changes materialized content', () => {
62
+ const pending = createPendingSteerMessage('local-1', [{ type: 'reference', reference: '@file' }], [])
63
+
64
+ expect(reconcilePendingSteers([steerBlock('materialized', 'resolved file content')], [pending])).toEqual([])
65
+ })
66
+
67
+ test('reconcile handles duplicate pending steer messages one materialized block at a time', () => {
68
+ const first = createPendingSteerMessage('local-1', text('duplicate'), [])
69
+ const second = createPendingSteerMessage('local-2', text('duplicate'), [])
70
+
71
+ expect(reconcilePendingSteers([steerBlock('materialized-1', 'duplicate')], [first, second])).toEqual([second])
72
+ expect(reconcilePendingSteers([steerBlock('materialized-1', 'duplicate'), steerBlock('materialized-2', 'duplicate')], [first, second])).toEqual([])
73
+ })
74
+
75
+ function text(value: string): UserContentBlock[] {
76
+ return [{ type: 'text', text: value }]
77
+ }
78
+
79
+ function steerBlock(id: string, value: string): Block {
80
+ return {
81
+ type: 'steer',
82
+ id,
83
+ turnId: 'turn-1',
84
+ createdAt: '2026-06-23T00:00:00.000Z',
85
+ model: null,
86
+ content: text(value),
87
+ } as unknown as Block
88
+ }
89
+
90
+ function videoSteerBlock(id: string, data: Uint8Array): Block {
91
+ return {
92
+ type: 'steer',
93
+ id,
94
+ turnId: 'turn-1',
95
+ createdAt: '2026-06-23T00:00:00.000Z',
96
+ model: null,
97
+ content: [{ type: 'video', source: { type: 'binary', mediaType: 'video/mp4', data } }],
98
+ } as unknown as Block
99
+ }
@@ -0,0 +1,14 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { queuedMessageIdForEmptySubmit } from '../queue-submit'
3
+
4
+ test('empty submit with no queue has no queued message target', () => {
5
+ expect(queuedMessageIdForEmptySubmit([])).toBeNull()
6
+ })
7
+
8
+ test('empty submit targets the last queued message', () => {
9
+ expect(queuedMessageIdForEmptySubmit([
10
+ { id: 'queued-first' },
11
+ { id: 'queued-second' },
12
+ { id: 'queued-last' },
13
+ ])).toBe('queued-last')
14
+ })
@@ -0,0 +1,32 @@
1
+ import { test, expect } from 'bun:test'
2
+ import { buildReasoningState } from '../reasoning'
3
+ import type { ModelInfo } from '../../transport/protocol'
4
+
5
+ const base: ModelInfo = {
6
+ id: 'm',
7
+ name: 'M',
8
+ contextWindow: 200000,
9
+ inputLimit: null,
10
+ acceptedExtensions: [],
11
+ reasoning: { efforts: ['low', 'medium', 'high'], defaultEffort: null, canDisable: true },
12
+ }
13
+
14
+ test('offers "No reasoning" when the model can disable thinking', () => {
15
+ const state = buildReasoningState(base)!
16
+ expect(state.canDisable).toBe(true)
17
+ expect(state.options.map((o) => o.label)).toContain('No reasoning')
18
+ expect(state.options[0]!.config).toEqual({ type: 'disabled' })
19
+ })
20
+
21
+ test('omits "No reasoning" when the model cannot disable thinking (e.g. Claude Code)', () => {
22
+ const state = buildReasoningState({ ...base, reasoning: { ...base.reasoning!, canDisable: false } })!
23
+ expect(state.canDisable).toBe(false)
24
+ expect(state.options.map((o) => o.label)).not.toContain('No reasoning')
25
+ expect(state.options.every((o) => o.config.type === 'effort')).toBe(true)
26
+ // the default selection is still a real effort, never "disabled"
27
+ expect(state.defaultConfig).toEqual({ type: 'effort', effort: 'low', summary: null })
28
+ })
29
+
30
+ test('no reasoning state when the model has no efforts', () => {
31
+ expect(buildReasoningState({ ...base, reasoning: null })).toBeNull()
32
+ })
@@ -0,0 +1,131 @@
1
+ import { expect, test } from 'bun:test'
2
+ import type { Block, ToolCallStatus } from '@demicodes/core'
3
+ import type { MessageListBlock } from '../pending-steers'
4
+ import { shouldShowTailLoading } from '../tail-loading'
5
+
6
+ test('tail loading appears while a running turn waits for the first model output', () => {
7
+ expect(shouldShowTailLoading('running', [])).toBe(true)
8
+ })
9
+
10
+ test('tail loading appears after user-like blocks while the turn is running', () => {
11
+ expect(shouldShowTailLoading('running', [userBlock()])).toBe(true)
12
+ expect(shouldShowTailLoading('running', [steerBlock()])).toBe(true)
13
+ expect(shouldShowTailLoading('running', [pendingSteerBlock()])).toBe(true)
14
+ expect(shouldShowTailLoading('running', [compactionBoundaryBlock()])).toBe(true)
15
+ })
16
+
17
+ test('tail loading appears after a completed tool while waiting for the model to continue', () => {
18
+ expect(shouldShowTailLoading('running', [toolCallBlock('completed')])).toBe(true)
19
+ expect(shouldShowTailLoading('running', [toolCallBlock('error')])).toBe(true)
20
+ })
21
+
22
+ test('tail loading stays hidden while the tool row itself is executing', () => {
23
+ expect(shouldShowTailLoading('running', [toolCallBlock('executing')])).toBe(false)
24
+ })
25
+
26
+ test('tail loading stays hidden outside running phase', () => {
27
+ expect(shouldShowTailLoading('idle', [])).toBe(false)
28
+ expect(shouldShowTailLoading('idle', [toolCallBlock('completed')])).toBe(false)
29
+ expect(shouldShowTailLoading('compacting', [userBlock()])).toBe(false)
30
+ })
31
+
32
+ test('tail loading stays hidden while assistant content is the latest visible block', () => {
33
+ expect(shouldShowTailLoading('running', [thinkingBlock()])).toBe(false)
34
+ expect(shouldShowTailLoading('running', [textBlock()])).toBe(false)
35
+ })
36
+
37
+ test('pending steer does not add tail loading while active thinking is already loading', () => {
38
+ const transcriptBlocks = [thinkingBlock()]
39
+ const renderBlocks = [...transcriptBlocks, pendingSteerBlock()]
40
+
41
+ expect(shouldShowTailLoading('running', transcriptBlocks, renderBlocks)).toBe(false)
42
+ })
43
+
44
+ test('tail loading appears after a materialized steer before the model continues', () => {
45
+ expect(shouldShowTailLoading('running', [thinkingBlock(), steerBlock()])).toBe(true)
46
+ })
47
+
48
+ const createdAt = '2026-06-24T00:00:00.000Z'
49
+ const model = null as unknown as BlockWithModel['model']
50
+
51
+ type BlockWithModel = Extract<Block, { model: unknown }>
52
+
53
+ function userBlock(): MessageListBlock {
54
+ return {
55
+ type: 'user',
56
+ id: 'user-1',
57
+ turnId: 'turn-1',
58
+ createdAt,
59
+ model,
60
+ content: [{ type: 'text', text: 'hello' }],
61
+ preamble: null,
62
+ }
63
+ }
64
+
65
+ function pendingSteerBlock(): MessageListBlock {
66
+ return {
67
+ type: 'pending_steer',
68
+ id: 'pending-steer-1',
69
+ pendingSteerId: 'pending-1',
70
+ content: [{ type: 'text', text: 'steer' }],
71
+ }
72
+ }
73
+
74
+ function steerBlock(): MessageListBlock {
75
+ return {
76
+ type: 'steer',
77
+ id: 'steer-1',
78
+ turnId: 'turn-1',
79
+ createdAt,
80
+ model,
81
+ content: [{ type: 'text', text: 'steer' }],
82
+ }
83
+ }
84
+
85
+ function compactionBoundaryBlock(): MessageListBlock {
86
+ return {
87
+ type: 'compaction_boundary',
88
+ id: 'compaction-1',
89
+ createdAt,
90
+ model,
91
+ summary: 'summary',
92
+ summaryTokens: 1,
93
+ }
94
+ }
95
+
96
+ function toolCallBlock(status: ToolCallStatus): MessageListBlock {
97
+ return {
98
+ type: 'tool_call',
99
+ id: `tool-${status}`,
100
+ createdAt,
101
+ model,
102
+ toolUseId: `tool-use-${status}`,
103
+ toolName: 'shell_exec',
104
+ input: '{"script":"ls"}',
105
+ status,
106
+ streamingOutput: [],
107
+ output: [],
108
+ view: null,
109
+ }
110
+ }
111
+
112
+ function thinkingBlock(): MessageListBlock {
113
+ return {
114
+ type: 'thinking',
115
+ id: 'thinking-1',
116
+ createdAt,
117
+ model,
118
+ text: 'thinking',
119
+ signature: null,
120
+ }
121
+ }
122
+
123
+ function textBlock(): MessageListBlock {
124
+ return {
125
+ type: 'text',
126
+ id: 'text-1',
127
+ createdAt,
128
+ model,
129
+ text: 'answer',
130
+ }
131
+ }
@@ -0,0 +1,35 @@
1
+ import { expect, test } from 'bun:test'
2
+ import {
3
+ isStandardToolName,
4
+ shouldParsePartialToolInput,
5
+ standardToolTitle,
6
+ trimToolSummary,
7
+ toolRenderKind,
8
+ } from '../tool-rendering'
9
+
10
+ test('standard tool titles prefer description and fall back by concrete tool', () => {
11
+ expect(standardToolTitle('shell_exec', { description: 'Run unit tests', script: 'bun test' })).toBe('Run unit tests')
12
+ expect(standardToolTitle('shell_exec', { script: 'bun test' })).toBe('bun test')
13
+ expect(standardToolTitle('shell_status', { commandId: 'cmd-1' })).toBe('Check cmd-1')
14
+ expect(standardToolTitle('shell_write', { commandId: 'cmd-1' })).toBe('Send input to cmd-1')
15
+ expect(standardToolTitle('shell_abort', { commandId: 'cmd-1' })).toBe('Stop cmd-1')
16
+ expect(standardToolTitle('yield', { durationMs: 250 })).toBe('Wait 250ms')
17
+ })
18
+
19
+ test('standard tool helpers distinguish Demi tools from unknown generic tools', () => {
20
+ expect(isStandardToolName('shell_exec')).toBe(true)
21
+ expect(isStandardToolName('shell_status')).toBe(true)
22
+ expect(isStandardToolName('shell_write')).toBe(true)
23
+ expect(isStandardToolName('shell_abort')).toBe(true)
24
+ expect(isStandardToolName('yield')).toBe(true)
25
+ expect(isStandardToolName('unknown_tool')).toBe(false)
26
+ expect(shouldParsePartialToolInput('shell_write')).toBe(true)
27
+ expect(shouldParsePartialToolInput('unknown_tool')).toBe(false)
28
+ expect(toolRenderKind('shell_exec')).toBe('shell_exec')
29
+ expect(toolRenderKind('shell_status')).toBe('shell_status')
30
+ expect(toolRenderKind('shell_write')).toBe('shell_write')
31
+ expect(toolRenderKind('shell_abort')).toBe('shell_abort')
32
+ expect(toolRenderKind('yield')).toBe('yield')
33
+ expect(toolRenderKind('unknown_tool')).toBe('generic')
34
+ expect(trimToolSummary(' a\n b ')).toBe('a b')
35
+ })
@@ -0,0 +1,125 @@
1
+ import { expect, test } from 'bun:test'
2
+ import type { Block, ModelSelection } from '@demicodes/core'
3
+ import { getVisibleBlocks } from '../visible-blocks'
4
+
5
+ test('visible blocks hide response blocks because web no longer renders response stats rows', () => {
6
+ const blocks = [
7
+ tool('yield-1', 'yield'),
8
+ response('response-after-yield'),
9
+ tool('status-1', 'shell_status'),
10
+ ]
11
+
12
+ expect(getVisibleBlocks(blocks).map((block) => block.id)).toEqual(['yield-1', 'status-1'])
13
+ })
14
+
15
+ test('visible blocks hide ordinary response blocks too', () => {
16
+ const blocks = [
17
+ tool('exec-1', 'shell_exec'),
18
+ response('response-after-exec'),
19
+ tool('status-1', 'shell_status'),
20
+ ]
21
+
22
+ expect(getVisibleBlocks(blocks).map((block) => block.id)).toEqual(['exec-1', 'status-1'])
23
+ })
24
+
25
+ test('visible blocks hide resume blocks because web has no resume row renderer', () => {
26
+ const blocks = [
27
+ tool('yield-1', 'yield'),
28
+ resume('resume-after-yield'),
29
+ tool('status-1', 'shell_status'),
30
+ ]
31
+
32
+ expect(getVisibleBlocks(blocks).map((block) => block.id)).toEqual(['yield-1', 'status-1'])
33
+ })
34
+
35
+ test('visible blocks hide hidden user/steer turns (internal yield wakeups) but keep real ones', () => {
36
+ const blocks = [
37
+ user('user-1', false),
38
+ tool('exec-1', 'shell_exec'),
39
+ user('wakeup-send', true),
40
+ steer('wakeup-steer', true),
41
+ steer('real-steer', false),
42
+ ]
43
+
44
+ expect(getVisibleBlocks(blocks).map((block) => block.id)).toEqual(['user-1', 'exec-1', 'real-steer'])
45
+ })
46
+
47
+ function tool(id: string, toolName: string): Extract<Block, { type: 'tool_call' }> {
48
+ return {
49
+ type: 'tool_call',
50
+ id,
51
+ createdAt: '1970-01-01T00:00:00.000Z',
52
+ model,
53
+ toolUseId: id,
54
+ toolName,
55
+ input: '{}',
56
+ status: 'completed',
57
+ streamingOutput: [],
58
+ output: [],
59
+ view: null,
60
+ }
61
+ }
62
+
63
+ function response(id: string): Extract<Block, { type: 'response' }> {
64
+ return {
65
+ type: 'response',
66
+ id,
67
+ createdAt: '1970-01-01T00:00:00.000Z',
68
+ model,
69
+ usage: {
70
+ inputTokens: 1,
71
+ outputTokens: 1,
72
+ cacheReadTokens: 0,
73
+ cacheWriteTokens: 0,
74
+ },
75
+ }
76
+ }
77
+
78
+ function resume(id: string): Extract<Block, { type: 'resume' }> {
79
+ return {
80
+ type: 'resume',
81
+ id,
82
+ turnId: id,
83
+ createdAt: '1970-01-01T00:00:00.000Z',
84
+ model,
85
+ }
86
+ }
87
+
88
+ function user(id: string, hidden: boolean): Extract<Block, { type: 'user' }> {
89
+ return {
90
+ type: 'user',
91
+ id,
92
+ turnId: id,
93
+ createdAt: '1970-01-01T00:00:00.000Z',
94
+ model,
95
+ content: [{ type: 'text', text: id }],
96
+ preamble: null,
97
+ ...(hidden ? { hidden: true } : {}),
98
+ }
99
+ }
100
+
101
+ function steer(id: string, hidden: boolean): Extract<Block, { type: 'steer' }> {
102
+ return {
103
+ type: 'steer',
104
+ id,
105
+ turnId: id,
106
+ createdAt: '1970-01-01T00:00:00.000Z',
107
+ model,
108
+ content: [{ type: 'text', text: id }],
109
+ ...(hidden ? { hidden: true } : {}),
110
+ }
111
+ }
112
+
113
+ const model: ModelSelection = {
114
+ providerId: 'test',
115
+ model: {
116
+ id: 'test-model',
117
+ name: 'Test Model',
118
+ contextWindow: 1000,
119
+ inputLimit: null,
120
+ thinking: [],
121
+ acceptedExtensions: [],
122
+ },
123
+ thinking: null,
124
+ serviceTierId: null,
125
+ }
@@ -0,0 +1,59 @@
1
+ import { isRecord } from '@demicodes/utils'
2
+ import type { Block, TokenUsage } from '@demicodes/core'
3
+
4
+ type ToolCallBlock = Extract<Block, { type: 'tool_call' }>
5
+
6
+ export interface ShellTerminalOutputChunk {
7
+ stream: 'stdout' | 'stderr'
8
+ text: string
9
+ }
10
+
11
+ export function getLatestResponseUsage(blocks: readonly Block[]): TokenUsage | null {
12
+ for (let i = blocks.length - 1; i >= 0; i--) {
13
+ const block = blocks[i]!
14
+ if (block.type === 'response') return block.usage
15
+ }
16
+ return null
17
+ }
18
+
19
+ export function getToolErrorText(block: ToolCallBlock): string | undefined {
20
+ if (block.status !== 'error') return undefined
21
+ const texts: string[] = []
22
+ for (const part of block.output) {
23
+ if (part.type === 'text') texts.push(part.text)
24
+ }
25
+ return texts.length > 0 ? texts.join('\n') : undefined
26
+ }
27
+
28
+ export function toolOutputText(block: ToolCallBlock): string {
29
+ const source = block.status === 'executing' ? block.streamingOutput : block.streamingOutput.length > 0 ? block.streamingOutput : block.output
30
+ return source
31
+ .filter((part): part is Extract<typeof part, { type: 'text' }> => part.type === 'text')
32
+ .map((part) => part.text)
33
+ .join('\n')
34
+ }
35
+
36
+ export function shellTerminalOutputChunks(block: ToolCallBlock): ShellTerminalOutputChunk[] {
37
+ return outputChunks(block.view)
38
+ }
39
+
40
+ export function parseToolInput(raw: string): Record<string, unknown> {
41
+ if (!raw) return {}
42
+ try {
43
+ const parsed = JSON.parse(raw)
44
+ return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : {}
45
+ } catch {
46
+ return {}
47
+ }
48
+ }
49
+
50
+ function outputChunks(view: unknown): ShellTerminalOutputChunk[] {
51
+ if (!isRecord(view) || !Array.isArray(view['chunks'])) return []
52
+ return view['chunks'].flatMap((chunk): ShellTerminalOutputChunk[] => {
53
+ if (!isRecord(chunk)) return []
54
+ const stream = chunk['stream']
55
+ const text = chunk['text']
56
+ if ((stream !== 'stdout' && stream !== 'stderr') || typeof text !== 'string' || text.length === 0) return []
57
+ return [{ stream, text }]
58
+ })
59
+ }
@@ -0,0 +1,12 @@
1
+ import type { Block } from '@demicodes/core'
2
+
3
+ // agent-gui exposed per-variant block interfaces; demi @demicodes/core exposes only the Block
4
+ // union, so we recover the named variants here for the ported components.
5
+
6
+ export type UserBlock = Extract<Block, { type: 'user' }>
7
+ export type SteerBlock = Extract<Block, { type: 'steer' }>
8
+ export type TextBlock = Extract<Block, { type: 'text' }>
9
+ export type ThinkingBlock = Extract<Block, { type: 'thinking' }>
10
+ export type ToolCallBlock = Extract<Block, { type: 'tool_call' }>
11
+ export type ResponseBlock = Extract<Block, { type: 'response' }>
12
+ export type ErrorBlock = Extract<Block, { type: 'error' }>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import { ArrowRightLine } from '@mingcute/vue/arrow-right'
3
+ import { t } from '@demicodes/web-ui/infra/i18n'
4
+
5
+ const emit = defineEmits<{
6
+ continue: []
7
+ retry: []
8
+ }>()
9
+
10
+ function handleRecovery() {
11
+ emit('continue')
12
+ }
13
+ </script>
14
+
15
+ <template>
16
+ <div class="flex h-6 items-center justify-between">
17
+ <span class="rounded bg-fg-ghost/60 px-1.5 py-0.5 text-[11px] text-fg-subtle">{{ t('agent.block.aborted') }}</span>
18
+ <div
19
+ class="flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-fg-subtle transition-colors hover:bg-active hover:text-fg-body"
20
+ @click="handleRecovery"
21
+ >
22
+ <ArrowRightLine :size="12" />
23
+ {{ t('common.continue') }}
24
+ </div>
25
+ </div>
26
+ </template>