@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,75 @@
1
+ import { computed, type ComputedRef } from 'vue'
2
+ import { useTheme } from '../theme/appTheme'
3
+
4
+ export interface TerminalTheme {
5
+ foreground: string
6
+ background: string
7
+ black: string
8
+ red: string
9
+ green: string
10
+ yellow: string
11
+ blue: string
12
+ magenta: string
13
+ cyan: string
14
+ white: string
15
+ brightBlack: string
16
+ brightRed: string
17
+ brightGreen: string
18
+ brightYellow: string
19
+ brightBlue: string
20
+ brightMagenta: string
21
+ brightCyan: string
22
+ brightWhite: string
23
+ }
24
+
25
+ const darkTheme: TerminalTheme = {
26
+ background: '#171717',
27
+ foreground: '#d4d4d8',
28
+ black: '#171717',
29
+ red: '#f87171',
30
+ green: '#4ade80',
31
+ yellow: '#facc15',
32
+ blue: '#60a5fa',
33
+ magenta: '#c084fc',
34
+ cyan: '#22d3ee',
35
+ white: '#d4d4d8',
36
+ brightBlack: '#525252',
37
+ brightRed: '#fca5a5',
38
+ brightGreen: '#86efac',
39
+ brightYellow: '#fde68a',
40
+ brightBlue: '#93c5fd',
41
+ brightMagenta: '#d8b4fe',
42
+ brightCyan: '#67e8f9',
43
+ brightWhite: '#fafafa',
44
+ }
45
+
46
+ const lightTheme: TerminalTheme = {
47
+ background: '#f9fafb',
48
+ foreground: '#1e1e1e',
49
+ black: '#1e1e1e',
50
+ red: '#dc2626',
51
+ green: '#16a34a',
52
+ yellow: '#a16207',
53
+ blue: '#2563eb',
54
+ magenta: '#9333ea',
55
+ cyan: '#0891b2',
56
+ white: '#d4d4d8',
57
+ brightBlack: '#737373',
58
+ brightRed: '#ef4444',
59
+ brightGreen: '#22c55e',
60
+ brightYellow: '#ca8a04',
61
+ brightBlue: '#3b82f6',
62
+ brightMagenta: '#a855f7',
63
+ brightCyan: '#06b6d4',
64
+ brightWhite: '#f5f5f5',
65
+ }
66
+
67
+ let terminalTheme: ComputedRef<TerminalTheme> | undefined
68
+
69
+ export function useTerminalTheme(): { terminalTheme: ComputedRef<TerminalTheme> } {
70
+ if (!terminalTheme) {
71
+ const { theme } = useTheme()
72
+ terminalTheme = computed<TerminalTheme>(() => (theme.value === 'dark' ? darkTheme : lightTheme))
73
+ }
74
+ return { terminalTheme }
75
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ declare module '*.vue' {
2
+ import type { DefineComponent } from 'vue'
3
+ const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
4
+ export default component
5
+ }
6
+
7
+ declare module '*.svg?raw' {
8
+ const content: string
9
+ export default content
10
+ }
@@ -0,0 +1,29 @@
1
+ import { reactive } from 'vue'
2
+
3
+ export interface Toast {
4
+ id: string
5
+ title: string
6
+ message: string
7
+ }
8
+
9
+ export interface ReportErrorOptions {
10
+ userVisible?: boolean
11
+ expected?: boolean
12
+ detail?: string
13
+ }
14
+
15
+ export const toasts = reactive<Toast[]>([])
16
+
17
+ export function reportError(title: string, error: unknown, options: ReportErrorOptions = {}): void {
18
+ const message = error instanceof Error ? error.message : String(error)
19
+ if (!options.expected) console.error(`[demi] ${title}: ${message}`, options.detail ?? '')
20
+ if (!options.userVisible) return
21
+ const id = globalThis.crypto.randomUUID()
22
+ toasts.push({ id, title, message })
23
+ setTimeout(() => dismissToast(id), 6000)
24
+ }
25
+
26
+ export function dismissToast(id: string): void {
27
+ const index = toasts.findIndex((toast) => toast.id === id)
28
+ if (index >= 0) toasts.splice(index, 1)
29
+ }
@@ -0,0 +1,41 @@
1
+ // Minimal English string table. Keys mirror agent-gui's i18n keys for the ported
2
+ // tab/list/input surfaces; unmapped keys fall back to the key itself.
3
+
4
+ const messages: Record<string, string> = {
5
+ 'common.rename': 'Rename',
6
+ 'common.close': 'Close',
7
+ 'agent.noWorkspace': 'No workspace open',
8
+ 'agent.newConversation': 'New conversation',
9
+ 'agent.tab.newTabRight': 'New tab to the right',
10
+ 'agent.tab.closeOthers': 'Close others',
11
+ 'agent.tab.closeToLeft': 'Close tabs to the left',
12
+ 'agent.tab.closeToRight': 'Close tabs to the right',
13
+ 'agent.tab.closeAll': 'Close all tabs',
14
+ 'agent.tab.copyConversationId': 'Copy conversation ID',
15
+ 'common.continue': 'Continue',
16
+ 'agent.block.thinking': 'Thinking',
17
+ 'agent.block.thinkingFor': 'Thinking for',
18
+ 'agent.block.thoughtFor': 'Thought for',
19
+ 'agent.block.error': 'Error',
20
+ 'agent.block.stackTrace': 'Stack trace',
21
+ 'agent.block.aborted': 'Aborted',
22
+ 'agent.stats.contextWindow': 'Context window',
23
+ 'agent.stats.cacheRead': 'Cache read',
24
+ 'agent.stats.input': 'Input',
25
+ 'agent.stats.output': 'Output',
26
+ 'agent.stats.cacheWrite': 'Cache write',
27
+ 'agent.stats.totalUsage': 'Total usage',
28
+ 'agent.stats.unavailable': 'N/A',
29
+ 'providers.model.thinking': 'Thinking',
30
+ 'providers.reasoning': 'Reasoning effort',
31
+ 'agent.context.compacting': 'Compacting context…',
32
+ 'agent.context.noUsage': 'No usage recorded yet',
33
+ 'agent.context.unavailable': 'Context usage unavailable',
34
+ 'agent.context.compactHint': 'Click to compact context',
35
+ 'agent.conversationList.placeholder': 'Search conversations…',
36
+ 'agent.conversationList.empty': 'No conversations',
37
+ }
38
+
39
+ export function t(key: string): string {
40
+ return messages[key] ?? key
41
+ }
@@ -0,0 +1,54 @@
1
+ const LINE_RANGE_SUFFIX_RE = /:\d+(?:-\d+)?$/
2
+
3
+ function joinPath(cwd: string, path: string): string {
4
+ const trimmedCwd = cwd.endsWith('/') ? cwd.slice(0, -1) : cwd
5
+ const trimmedPath = path.replace(/^\.\/+/, '')
6
+ return `${trimmedCwd}/${trimmedPath}`.replace(/\/{2,}/g, '/')
7
+ }
8
+
9
+ export function normalizeFilePath(rawPath: string): string {
10
+ const trimmedPath = rawPath.trim()
11
+ if (!trimmedPath) return ''
12
+
13
+ const withoutScheme = trimmedPath.startsWith('file://')
14
+ ? trimmedPath.slice('file://'.length)
15
+ : trimmedPath
16
+ const withoutLineRange = withoutScheme.replace(LINE_RANGE_SUFFIX_RE, '')
17
+ const withoutCurrentDirPrefix = withoutLineRange.replace(/^\.\/+/, '')
18
+
19
+ if (withoutCurrentDirPrefix.length > 1 && withoutCurrentDirPrefix.endsWith('/')) {
20
+ return withoutCurrentDirPrefix.slice(0, -1)
21
+ }
22
+ return withoutCurrentDirPrefix
23
+ }
24
+
25
+ export function resolveAbsolutePath(cwd: string, rawPath: string): string {
26
+ const normalizedPath = normalizeFilePath(rawPath)
27
+ if (!normalizedPath) return ''
28
+ return normalizedPath.startsWith('/') ? normalizedPath : joinPath(cwd, normalizedPath)
29
+ }
30
+
31
+ export function isHttpUrl(path: string): boolean {
32
+ return path.startsWith('http://') || path.startsWith('https://')
33
+ }
34
+
35
+ export function isLikelyFilePath(rawPath: string): boolean {
36
+ const trimmedPath = rawPath.trim()
37
+ const normalizedPath = normalizeFilePath(trimmedPath)
38
+ if (!normalizedPath) return false
39
+ if (normalizedPath.startsWith('#')) return false
40
+ if (normalizedPath.startsWith('mailto:')) return false
41
+ if (isHttpUrl(normalizedPath)) return false
42
+
43
+ if (trimmedPath.endsWith('/')) return true
44
+
45
+ return normalizedPath.startsWith('/')
46
+ || normalizedPath.startsWith('./')
47
+ || normalizedPath.startsWith('../')
48
+ || normalizedPath.includes('/')
49
+ || /^[^/\\\s]+\.[^/\\\s]+$/.test(normalizedPath)
50
+ }
51
+
52
+ export function toLocalFileUrl(filePath: string): string {
53
+ return `local-file://${encodeURI(filePath)}`
54
+ }
@@ -0,0 +1,139 @@
1
+ import { readonly, shallowRef } from 'vue'
2
+ import { createHighlighter, type BundledLanguage, type BundledTheme, type Highlighter } from 'shiki'
3
+ import { codeThemes, findCodeTheme } from '../theme/codeThemes'
4
+ import type { MarkdownThemeSnapshot } from './types'
5
+
6
+ const ALL_SHIKI_THEMES: BundledTheme[] = [
7
+ ...new Set(codeThemes.flatMap((theme) => [theme.dark.shikiTheme, theme.light.shikiTheme])),
8
+ ]
9
+
10
+ const LANGS = [
11
+ 'typescript', 'javascript', 'tsx', 'jsx', 'json', 'jsonc',
12
+ 'html', 'css', 'scss', 'less', 'vue',
13
+ 'python', 'ruby', 'rust', 'go', 'java', 'kotlin', 'swift', 'dart', 'php',
14
+ 'c', 'cpp', 'csharp',
15
+ 'bash',
16
+ 'yaml', 'toml', 'ini', 'xml', 'sql', 'graphql',
17
+ 'markdown', 'dockerfile', 'makefile',
18
+ ] as const
19
+
20
+ const LANG_ALIASES: Record<string, string> = {
21
+ shell: 'bash',
22
+ sh: 'bash',
23
+ zsh: 'bash',
24
+ js: 'javascript',
25
+ ts: 'typescript',
26
+ py: 'python',
27
+ rb: 'ruby',
28
+ rs: 'rust',
29
+ cs: 'csharp',
30
+ yml: 'yaml',
31
+ }
32
+
33
+ const highlighter = shallowRef<Highlighter | null>(null)
34
+ const renderVersion = shallowRef(0)
35
+
36
+ const highlighterReady = createHighlighter({ themes: ALL_SHIKI_THEMES, langs: [...LANGS] }).then((instance) => {
37
+ highlighter.value = instance
38
+ renderVersion.value += 1
39
+ })
40
+
41
+ function escapeHtml(str: string): string {
42
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
43
+ }
44
+
45
+ function detectThemeMode(): 'light' | 'dark' {
46
+ if (typeof document === 'undefined') return 'dark'
47
+ return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'
48
+ }
49
+
50
+ function getShikiTheme(theme?: MarkdownThemeSnapshot): BundledTheme {
51
+ const snapshot = theme ?? { mode: detectThemeMode(), codeThemeId: codeThemes[0]?.id ?? 'one' }
52
+ const definition = findCodeTheme(snapshot.codeThemeId)
53
+ return snapshot.mode === 'light' ? definition.light.shikiTheme : definition.dark.shikiTheme
54
+ }
55
+
56
+ function resolveLang(lang: string): string {
57
+ if (!lang || lang === 'text' || lang === 'plaintext') return 'text'
58
+ const instance = highlighter.value
59
+ if (!instance) return 'text'
60
+ const resolved = LANG_ALIASES[lang] ?? lang
61
+ return instance.getLoadedLanguages().includes(resolved as BundledLanguage) ? resolved : 'text'
62
+ }
63
+
64
+ export function codeToHtml(code: string, lang: string, theme?: MarkdownThemeSnapshot): string {
65
+ const instance = highlighter.value
66
+ if (!instance) return `<pre><code>${escapeHtml(code)}</code></pre>`
67
+
68
+ return instance.codeToHtml(code, {
69
+ lang: resolveLang(lang),
70
+ theme: getShikiTheme(theme),
71
+ transformers: [{
72
+ pre(node) {
73
+ const style = String(node.properties?.['style'] ?? '')
74
+ node.properties['style'] = style.replace(/background-color:[^;]+;?\s*/g, '').trim() || undefined
75
+ },
76
+ }],
77
+ })
78
+ }
79
+
80
+ export interface TokenSpan {
81
+ content: string
82
+ color?: string
83
+ }
84
+
85
+ export function codeToTokenLines(code: string, lang: string, theme?: MarkdownThemeSnapshot): TokenSpan[][] {
86
+ const instance = highlighter.value
87
+ if (!instance) {
88
+ return splitLines(code).map((line) => [{ content: line }])
89
+ }
90
+ const resolved = resolveLang(lang) as BundledLanguage
91
+ const { tokens } = instance.codeToTokens(code, { lang: resolved, theme: getShikiTheme(theme) })
92
+ return tokens.map((line) => line.map((t): TokenSpan => t.color ? { content: t.content, color: t.color } : { content: t.content }))
93
+ }
94
+
95
+ const EXT_TO_LANG: Record<string, string> = {
96
+ '.ts': 'typescript', '.tsx': 'tsx', '.js': 'javascript', '.jsx': 'jsx',
97
+ '.cjs': 'javascript', '.mjs': 'javascript',
98
+ '.json': 'json', '.jsonc': 'jsonc',
99
+ '.vue': 'vue', '.html': 'html', '.htm': 'html',
100
+ '.css': 'css', '.scss': 'scss', '.less': 'less',
101
+ '.md': 'markdown',
102
+ '.py': 'python', '.rb': 'ruby', '.rs': 'rust', '.go': 'go',
103
+ '.java': 'java', '.kt': 'kotlin', '.swift': 'swift', '.dart': 'dart', '.php': 'php',
104
+ '.c': 'c', '.h': 'c', '.cpp': 'cpp', '.cc': 'cpp', '.hpp': 'cpp', '.cs': 'csharp',
105
+ '.sh': 'bash', '.zsh': 'bash', '.bash': 'bash',
106
+ '.yml': 'yaml', '.yaml': 'yaml',
107
+ '.xml': 'xml', '.svg': 'xml',
108
+ '.sql': 'sql', '.graphql': 'graphql',
109
+ '.toml': 'toml', '.ini': 'ini', '.env': 'ini',
110
+ }
111
+
112
+ const FILENAME_TO_LANG: Record<string, string> = {
113
+ Dockerfile: 'dockerfile',
114
+ Makefile: 'makefile',
115
+ '.gitignore': 'ini',
116
+ }
117
+
118
+ export function getLanguageFromPath(filepath: string): string {
119
+ const filename = filepath.split('/').pop() ?? ''
120
+ const byName = FILENAME_TO_LANG[filename]
121
+ if (byName) return byName
122
+ const dotIndex = filename.lastIndexOf('.')
123
+ if (dotIndex <= 0) return 'text'
124
+ return EXT_TO_LANG[filename.slice(dotIndex).toLowerCase()] ?? 'text'
125
+ }
126
+
127
+ export function useMarkdownRenderVersion() {
128
+ return readonly(renderVersion)
129
+ }
130
+
131
+ export async function waitForMarkdownHighlighter() {
132
+ await highlighterReady
133
+ }
134
+
135
+ function splitLines(code: string): string[] {
136
+ const lines = code.split('\n')
137
+ if (lines.at(-1) === '') lines.pop()
138
+ return lines
139
+ }
@@ -0,0 +1,58 @@
1
+ import { Marked } from 'marked'
2
+ import DOMPurify from 'dompurify'
3
+ import { renderMarkdown } from './render'
4
+ import { isHttpUrl } from './filePath'
5
+ import { codeToHtml } from './highlight'
6
+ import type { MarkdownRenderOptions } from './types'
7
+
8
+ function escapeHtml(text: string): string {
9
+ return text
10
+ .replace(/&/g, '&amp;')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;')
14
+ }
15
+
16
+ const userMarked = new Marked({
17
+ gfm: true,
18
+ breaks: true,
19
+ renderer: {
20
+ html({ text }) {
21
+ return escapeHtml(text)
22
+ },
23
+ code({ text, lang }) {
24
+ return codeToHtml(text, lang ?? '')
25
+ },
26
+ codespan({ text }) {
27
+ return `<code>${escapeHtml(text)}</code>`
28
+ },
29
+ link(token) {
30
+ const href = token.href
31
+ const body = this.parser.parseInline(token.tokens)
32
+ if (isHttpUrl(href)) {
33
+ return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${body}</a>`
34
+ }
35
+ return body
36
+ },
37
+ },
38
+ })
39
+ // Deliberately no KaTeX on user content: people type `$` for shell vars ($PATH), prices,
40
+ // and when discussing LaTeX itself, so rendering math here causes far more false positives
41
+ // than it's worth. Math rendering applies to assistant output only (see render.ts).
42
+
43
+ function escapeBlockSyntax(src: string): string {
44
+ return src
45
+ .replace(/^(\d+)([.)]) /gm, '$1\\$2 ')
46
+ .replace(/^([-*+]) /gm, '\\$1 ')
47
+ .replace(/^(#{1,6}) /gm, '\\$1 ')
48
+ .replace(/^(>)/gm, '\\$1')
49
+ }
50
+
51
+ export const md = {
52
+ render(src: string, options?: MarkdownRenderOptions): string {
53
+ return DOMPurify.sanitize(renderMarkdown(src, options), { ADD_ATTR: ['style', 'data-file-link'] })
54
+ },
55
+ renderUser(src: string): string {
56
+ return DOMPurify.sanitize(userMarked.parse(escapeBlockSyntax(src), { async: false }) as string)
57
+ },
58
+ }
@@ -0,0 +1,117 @@
1
+ import { Marked } from 'marked'
2
+ import markedKatex from 'marked-katex-extension'
3
+ import type { MarkdownRenderOptions } from './types'
4
+ import { codeToHtml } from './highlight'
5
+ import { isHttpUrl, isLikelyFilePath, normalizeFilePath, resolveAbsolutePath, toLocalFileUrl } from './filePath'
6
+
7
+ // `$...$` inline / `$$...$$` block LaTeX, rendered to self-contained HTML (KaTeX CSS is loaded
8
+ // by the app). `nonStandard` lets inline math sit flush against CJK text the model writes;
9
+ // `throwOnError` keeps malformed math from blowing up the whole message.
10
+ const katexExtension = markedKatex({ throwOnError: false, nonStandard: true, output: 'html' })
11
+
12
+ const INLINE_CODE_RE = /`([^`\n]+)`/g
13
+ const MARKDOWN_LINK_RE = /\[[^\]]*]\(([^)]+)\)/g
14
+
15
+ function escapeHtml(text: string): string {
16
+ return text
17
+ .replace(/&/g, '&amp;')
18
+ .replace(/</g, '&lt;')
19
+ .replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;')
21
+ }
22
+
23
+ function extractLinkHref(rawTarget: string): string {
24
+ const trimmed = rawTarget.trim()
25
+ if (!trimmed) return ''
26
+
27
+ const unwrapped = trimmed.startsWith('<') && trimmed.endsWith('>')
28
+ ? trimmed.slice(1, -1)
29
+ : trimmed
30
+ const separatorIndex = unwrapped.search(/\s/)
31
+ return separatorIndex === -1 ? unwrapped : unwrapped.slice(0, separatorIndex)
32
+ }
33
+
34
+ export function extractFilePathCandidates(src: string): Set<string> {
35
+ const candidates = new Set<string>()
36
+
37
+ for (const match of src.matchAll(INLINE_CODE_RE)) {
38
+ const codeText = match[1]
39
+ if (!codeText || !isLikelyFilePath(codeText)) continue
40
+ const normalizedPath = normalizeFilePath(codeText)
41
+ if (normalizedPath) candidates.add(normalizedPath)
42
+ }
43
+
44
+ for (const match of src.matchAll(MARKDOWN_LINK_RE)) {
45
+ const rawHref = match[1]
46
+ if (!rawHref) continue
47
+ const href = extractLinkHref(rawHref)
48
+ if (!isLikelyFilePath(href)) continue
49
+ const normalizedPath = normalizeFilePath(href)
50
+ if (normalizedPath) candidates.add(normalizedPath)
51
+ }
52
+
53
+ return candidates
54
+ }
55
+
56
+ function resolveImageSource(href: string, basePath?: string): string {
57
+ const trimmedHref = href.trim()
58
+ if (!trimmedHref) return ''
59
+ if (isHttpUrl(trimmedHref) || trimmedHref.startsWith('data:') || trimmedHref.startsWith('local-file:')) return trimmedHref
60
+ if (!basePath) return trimmedHref
61
+ const absPath = resolveAbsolutePath(basePath, trimmedHref)
62
+ return absPath ? toLocalFileUrl(absPath) : trimmedHref
63
+ }
64
+
65
+ function createMarked(options?: MarkdownRenderOptions) {
66
+ const knownPaths = options?.knownPaths
67
+ const basePath = options?.basePath
68
+
69
+ const marked = new Marked({
70
+ gfm: true,
71
+ breaks: true,
72
+ renderer: {
73
+ html({ text }) {
74
+ return escapeHtml(text)
75
+ },
76
+ code({ text, lang }) {
77
+ return codeToHtml(text, lang ?? '', options?.theme)
78
+ },
79
+ codespan({ text }) {
80
+ const normalizedPath = normalizeFilePath(text)
81
+ if (isLikelyFilePath(text) && knownPaths?.has(normalizedPath)) {
82
+ return `<a class="file-link" href="${escapeHtml(normalizedPath)}" data-file-link>${escapeHtml(text)}</a>`
83
+ }
84
+ return `<code>${escapeHtml(text)}</code>`
85
+ },
86
+ link(token) {
87
+ const href = extractLinkHref(token.href)
88
+ const body = this.parser.parseInline(token.tokens)
89
+ if (isHttpUrl(href)) {
90
+ return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${body}</a>`
91
+ }
92
+ if (isLikelyFilePath(href)) {
93
+ const normalizedPath = normalizeFilePath(href)
94
+ if (!knownPaths || knownPaths.has(normalizedPath)) {
95
+ return `<a href="${escapeHtml(normalizedPath)}" data-file-link>${body}</a>`
96
+ }
97
+ }
98
+ return body
99
+ },
100
+ image(token) {
101
+ const href = extractLinkHref(token.href)
102
+ const safeSrc = resolveImageSource(href, basePath)
103
+ if (!safeSrc) return escapeHtml(token.text)
104
+
105
+ const title = token.title ? ` title="${escapeHtml(token.title)}"` : ''
106
+ const alt = escapeHtml(token.text)
107
+ return `<img src="${escapeHtml(safeSrc)}" alt="${alt}"${title} />`
108
+ },
109
+ },
110
+ })
111
+ marked.use(katexExtension)
112
+ return marked
113
+ }
114
+
115
+ export function renderMarkdown(src: string, options?: MarkdownRenderOptions): string {
116
+ return createMarked(options).parse(src, { async: false }) as string
117
+ }
@@ -0,0 +1,20 @@
1
+ export interface MarkdownThemeSnapshot {
2
+ mode: 'light' | 'dark'
3
+ codeThemeId: string
4
+ }
5
+
6
+ export interface MarkdownRenderOptions {
7
+ knownPaths?: Set<string>
8
+ basePath?: string
9
+ theme?: MarkdownThemeSnapshot
10
+ }
11
+
12
+ export type MarkdownRenderer = (src: string, options?: MarkdownRenderOptions) => string
13
+
14
+ export interface MarkdownLinkClick {
15
+ href: string
16
+ event: MouseEvent
17
+ basePath?: string
18
+ }
19
+
20
+ export type MarkdownLinkHandler = (payload: MarkdownLinkClick) => void
@@ -0,0 +1,3 @@
1
+ import { createOverlayStore } from './overlayStore'
2
+
3
+ export const appOverlayStore = createOverlayStore()
@@ -0,0 +1,54 @@
1
+ import { createStore } from '../store/createStore'
2
+
3
+ export interface OverlayEntry {
4
+ id: string
5
+ close: () => void
6
+ }
7
+
8
+ export interface OverlayStore {
9
+ state: {
10
+ entries: OverlayEntry[]
11
+ }
12
+ hasEntries(): boolean
13
+ closeTop(): void
14
+ push(id: string, close: () => void): () => void
15
+ remove(id: string): void
16
+ subscribe(listener: () => void): () => void
17
+ }
18
+
19
+ export function createOverlayStore(): OverlayStore {
20
+ const store = createStore<{ entries: OverlayEntry[] }>({ entries: [] })
21
+
22
+ return {
23
+ state: store.state,
24
+ subscribe: store.subscribe,
25
+ hasEntries() {
26
+ return store.state.entries.length > 0
27
+ },
28
+ closeTop() {
29
+ const top = store.state.entries[store.state.entries.length - 1]
30
+ if (!top) return
31
+ top.close()
32
+ },
33
+ push(id, close) {
34
+ store.update((state) => {
35
+ state.entries.push({ id, close })
36
+ })
37
+
38
+ return () => {
39
+ const index = store.state.entries.findIndex((entry) => entry.id === id)
40
+ if (index < 0) return
41
+ store.update((state) => {
42
+ state.entries.splice(index, 1)
43
+ })
44
+ }
45
+ },
46
+ remove(id) {
47
+ const index = store.state.entries.findIndex((entry) => entry.id === id)
48
+ if (index < 0) return
49
+ store.update((state) => {
50
+ state.entries.splice(index, 1)
51
+ })
52
+ },
53
+ }
54
+ }
@@ -0,0 +1,33 @@
1
+ import { reactive } from 'vue'
2
+
3
+ export interface Store<T extends object> {
4
+ state: T
5
+ patch(partial: Partial<T>): void
6
+ update(mutator: (state: T) => void): void
7
+ subscribe(listener: () => void): () => void
8
+ }
9
+
10
+ export function createStore<T extends object>(initialState: T): Store<T> {
11
+ const state = reactive(initialState) as T
12
+ const listeners = new Set<() => void>()
13
+
14
+ function notify() {
15
+ for (const listener of listeners) listener()
16
+ }
17
+
18
+ return {
19
+ state,
20
+ patch(partial) {
21
+ Object.assign(state, partial)
22
+ notify()
23
+ },
24
+ update(mutator) {
25
+ mutator(state)
26
+ notify()
27
+ },
28
+ subscribe(listener) {
29
+ listeners.add(listener)
30
+ return () => listeners.delete(listener)
31
+ },
32
+ }
33
+ }
@@ -0,0 +1,7 @@
1
+ export interface StoreStateSource<T extends object> {
2
+ state: T
3
+ }
4
+
5
+ export function useStore<T extends object>(store: StoreStateSource<T>): T {
6
+ return store.state
7
+ }