sparkdesign 0.0.1 → 0.1.10

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 (118) hide show
  1. package/README.md +188 -4
  2. package/dist/commands/add.js +93 -0
  3. package/dist/commands/diff.js +54 -0
  4. package/dist/commands/init.js +96 -0
  5. package/dist/commands/list.js +25 -0
  6. package/dist/index.js +37 -0
  7. package/dist/utils/config.js +53 -0
  8. package/dist/utils/registry.js +34 -0
  9. package/dist/utils/tokens.js +176 -0
  10. package/dist/utils/transform.js +19 -0
  11. package/package.json +33 -10
  12. package/registry/__tests__/basic/button.test.tsx +333 -0
  13. package/registry/__tests__/chat/markdown.test.tsx +387 -0
  14. package/registry/__tests__/chat/thinking-indicator.test.tsx +244 -0
  15. package/registry/__tests__/chat/tool-invocation-card.test.tsx +346 -0
  16. package/registry/basic/alert-dialog.tsx +180 -0
  17. package/registry/basic/avatar.tsx +120 -0
  18. package/registry/basic/button.tsx +100 -0
  19. package/registry/basic/collapse.tsx +94 -0
  20. package/registry/basic/collapsible-card.tsx +230 -0
  21. package/registry/basic/collapsible.tsx +21 -0
  22. package/registry/basic/dropdown-menu.tsx +254 -0
  23. package/registry/basic/icon-button.tsx +66 -0
  24. package/registry/basic/icons-inline.tsx +206 -0
  25. package/registry/basic/kbd.tsx +50 -0
  26. package/registry/basic/option-list.tsx +125 -0
  27. package/registry/basic/pagination.tsx +132 -0
  28. package/registry/basic/progress.tsx +42 -0
  29. package/registry/basic/radio-group.tsx +69 -0
  30. package/registry/basic/resizable.tsx +67 -0
  31. package/registry/basic/scrollbar.tsx +114 -0
  32. package/registry/basic/select.tsx +177 -0
  33. package/registry/basic/shimmering-text.tsx +115 -0
  34. package/registry/basic/sidebar-menu.tsx +177 -0
  35. package/registry/basic/skeleton.tsx +33 -0
  36. package/registry/basic/slider.tsx +55 -0
  37. package/registry/basic/sonner.tsx +104 -0
  38. package/registry/basic/spinner.tsx +17 -0
  39. package/registry/basic/switch.tsx +49 -0
  40. package/registry/basic/table.tsx +117 -0
  41. package/registry/basic/tabs.tsx +85 -0
  42. package/registry/basic/tag.tsx +161 -0
  43. package/registry/basic/theme-from-document.ts +10 -0
  44. package/registry/basic/toggle.tsx +223 -0
  45. package/registry/basic/tooltip.tsx +80 -0
  46. package/registry/basic/typography.tsx +201 -0
  47. package/registry/chat/ask-user-part.tsx +70 -0
  48. package/registry/chat/browser-action-part.tsx +166 -0
  49. package/registry/chat/chat-input/chat-input-folder-selector.tsx +185 -0
  50. package/registry/chat/chat-input/chat-input-model-switcher.tsx +131 -0
  51. package/registry/chat/chat-input/chat-input-textarea.tsx +67 -0
  52. package/registry/chat/chat-input/compound.tsx +334 -0
  53. package/registry/chat/chat-input/context.tsx +189 -0
  54. package/registry/chat/chat-input/folder-permission-dialog.tsx +61 -0
  55. package/registry/chat/chat-input/index.tsx +123 -0
  56. package/registry/chat/chat-input/types.ts +77 -0
  57. package/registry/chat/chat-input/useAutoResizeTextarea.ts +20 -0
  58. package/registry/chat/code-block-part.tsx +151 -0
  59. package/registry/chat/file-attachment.tsx +44 -0
  60. package/registry/chat/file-card.tsx +68 -0
  61. package/registry/chat/file-review-part.tsx +259 -0
  62. package/registry/chat/folder-button.tsx +169 -0
  63. package/registry/chat/generated-images-grid.tsx +56 -0
  64. package/registry/chat/generation-status-bar.tsx +72 -0
  65. package/registry/chat/hint-banner.tsx +165 -0
  66. package/registry/chat/image-attachment.tsx +166 -0
  67. package/registry/chat/image-generating.tsx +281 -0
  68. package/registry/chat/markdown.tsx +146 -0
  69. package/registry/chat/mermaid-part.tsx +90 -0
  70. package/registry/chat/permission-card.tsx +178 -0
  71. package/registry/chat/plan-part.tsx +168 -0
  72. package/registry/chat/queue-indicator.tsx +234 -0
  73. package/registry/chat/reasoning-step/compound.tsx +336 -0
  74. package/registry/chat/reasoning-step/context.tsx +114 -0
  75. package/registry/chat/reasoning-step/index.tsx +45 -0
  76. package/registry/chat/reasoning-step/types.ts +109 -0
  77. package/registry/chat/related-prompts.tsx +91 -0
  78. package/registry/chat/response/compound.tsx +210 -0
  79. package/registry/chat/response/context.tsx +200 -0
  80. package/registry/chat/response/index.tsx +87 -0
  81. package/registry/chat/response/types.ts +123 -0
  82. package/registry/chat/send-button.tsx +94 -0
  83. package/registry/chat/streaming-markdown-block.tsx +111 -0
  84. package/registry/chat/task-part.tsx +109 -0
  85. package/registry/chat/terminal-code-block-part.tsx +69 -0
  86. package/registry/chat/thinking-indicator.tsx +91 -0
  87. package/registry/chat/tool-invocation-card.tsx +132 -0
  88. package/registry/chat/user-message.tsx +38 -0
  89. package/registry/chat/user-question/UserQuestionCard.tsx +198 -0
  90. package/registry/chat/user-question/UserQuestionFooter.tsx +66 -0
  91. package/registry/chat/user-question/UserQuestionHeader.tsx +64 -0
  92. package/registry/chat/user-question/compound.tsx +324 -0
  93. package/registry/chat/user-question/context.tsx +456 -0
  94. package/registry/chat/user-question/index.tsx +95 -0
  95. package/registry/chat/user-question/types.ts +61 -0
  96. package/registry/chat/user-question/useUserQuestionKeyboard.ts +126 -0
  97. package/registry/chat/user-question/useUserQuestionState.ts +165 -0
  98. package/registry/chat/user-question-answer.tsx +62 -0
  99. package/registry/lib/file-icon-maps.ts +150 -0
  100. package/registry/lib/use-mermaid-render.ts +76 -0
  101. package/registry/lib/utils.ts +6 -0
  102. package/registry/meta.json +1 -0
  103. package/registry/tokens/index.css +31 -0
  104. package/registry/tokens/scale/computed.css +103 -0
  105. package/registry/tokens/scale/config.css +110 -0
  106. package/registry/tokens/scale/index.css +30 -0
  107. package/registry/tokens/scale/presets/compact.css +30 -0
  108. package/registry/tokens/scale/presets/dense.css +64 -0
  109. package/registry/tokens/scale/presets/sharp.css +40 -0
  110. package/registry/tokens/scale/presets/soft.css +16 -0
  111. package/registry/tokens/scale.css +13 -0
  112. package/registry/tokens/scrollbar-utility.css +35 -0
  113. package/registry/tokens/theme.css +633 -0
  114. package/registry/tokens/themes/dark-parchment.css +132 -0
  115. package/registry/tokens/themes/dark-qoder.css +132 -0
  116. package/registry/tokens/themes/light-parchment.css +123 -0
  117. package/registry/tokens/themes/light-qoder.css +131 -0
  118. package/index.js +0 -5
@@ -0,0 +1,87 @@
1
+ import type { HTMLAttributes } from 'react'
2
+ import { ResponseRootProvider, useResponseContext, DEMO_ROUNDS, DEMO_FINAL_MD } from './context'
3
+ import {
4
+ ResponseThinking,
5
+ ResponseSteps,
6
+ ResponseStepComponent,
7
+ ResponseContent,
8
+ ResponseImageGenerating,
9
+ ResponseDefaultLayout,
10
+ } from './compound'
11
+ import type {
12
+ ResponseProps,
13
+ ResponseCompound,
14
+ ResponsePhase,
15
+ ResponseStep,
16
+ ResponseStepToolType,
17
+ ResponseRound,
18
+ ResponseBlock,
19
+ BlockType,
20
+ } from './types'
21
+
22
+ // ===== Response 便捷组件 =====
23
+
24
+ function ResponseBase({
25
+ simulate,
26
+ phase,
27
+ rounds,
28
+ finalMarkdown,
29
+ onSimulateComplete,
30
+ onStepChange,
31
+ thinkingIndicator,
32
+ className,
33
+ ...props
34
+ }: ResponseProps & HTMLAttributes<HTMLDivElement>) {
35
+ return (
36
+ <ResponseRootProvider
37
+ simulate={simulate}
38
+ phase={phase}
39
+ rounds={rounds}
40
+ finalMarkdown={finalMarkdown}
41
+ onSimulateComplete={onSimulateComplete}
42
+ onStepChange={onStepChange}
43
+ thinkingIndicator={thinkingIndicator}
44
+ >
45
+ <div {...props}>
46
+ <ResponseDefaultLayout className={className} />
47
+ </div>
48
+ </ResponseRootProvider>
49
+ )
50
+ }
51
+
52
+ ResponseBase.displayName = 'Response'
53
+
54
+ // ===== 挂载复合组件 =====
55
+
56
+ type ResponseBaseType = React.FC<ResponseProps & HTMLAttributes<HTMLDivElement>> &
57
+ Partial<ResponseCompound>
58
+
59
+ const ResponseWithSlots = ResponseBase as ResponseBaseType
60
+
61
+ ResponseWithSlots.Root = ResponseRootProvider
62
+ ResponseWithSlots.Thinking = ResponseThinking
63
+ ResponseWithSlots.Steps = ResponseSteps
64
+ ResponseWithSlots.Step = ResponseStepComponent
65
+ ResponseWithSlots.Content = ResponseContent
66
+ ResponseWithSlots.ImageGenerating = ResponseImageGenerating
67
+ ResponseWithSlots.DefaultLayout = ResponseDefaultLayout
68
+
69
+ export const Response = ResponseWithSlots as React.FC<
70
+ ResponseProps & HTMLAttributes<HTMLDivElement>
71
+ > &
72
+ ResponseCompound
73
+
74
+ // ===== 导出类型和工具 =====
75
+
76
+ export type {
77
+ ResponseProps,
78
+ ResponsePhase,
79
+ ResponseStep,
80
+ ResponseStepToolType,
81
+ ResponseRound,
82
+ ResponseBlock,
83
+ BlockType,
84
+ ResponseCompound,
85
+ }
86
+
87
+ export { useResponseContext, DEMO_ROUNDS, DEMO_FINAL_MD }
@@ -0,0 +1,123 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react'
2
+ import type { ReasoningStepDetail } from '../reasoning-step'
3
+
4
+ // ===== 基础类型 =====
5
+
6
+ export type ResponsePhase = 'thinking' | 'streaming' | 'done' | 'imageGenerating'
7
+ export type ResponseStepToolType = 'read' | 'edit' | 'search'
8
+
9
+ export interface ResponseStep {
10
+ text: string
11
+ description?: string
12
+ details?: ReasoningStepDetail[]
13
+ toolType?: ResponseStepToolType
14
+ }
15
+
16
+ export interface ResponseRound {
17
+ step: ResponseStep
18
+ simpleMd: string
19
+ }
20
+
21
+ // ===== Block 类型(内部) =====
22
+
23
+ export type BlockType = 'think' | 'composer' | 'md'
24
+
25
+ export interface ResponseBlock {
26
+ type: BlockType
27
+ step?: ResponseStep
28
+ md?: string
29
+ }
30
+
31
+ // ===== 组件 Props =====
32
+
33
+ export interface ResponseRootProps {
34
+ /** 是否启用流式模拟 */
35
+ simulate?: boolean
36
+ /** 当前阶段 */
37
+ phase?: ResponsePhase
38
+ /** 响应轮次数据 */
39
+ rounds?: ResponseRound[]
40
+ /** 最终 Markdown 内容 */
41
+ finalMarkdown?: string
42
+ /** 模拟完成回调 */
43
+ onSimulateComplete?: () => void
44
+ /** 步骤变化回调 */
45
+ onStepChange?: (text: string, toolType?: ResponseStepToolType) => void
46
+ /** 自定义 ThinkingIndicator(主库注入 Lottie) */
47
+ thinkingIndicator?: ReactNode
48
+ /** 自定义 className */
49
+ className?: string
50
+ /** 子组件 */
51
+ children?: ReactNode
52
+ }
53
+
54
+ export interface ResponseProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>, ResponseRootProps {}
55
+
56
+ export interface ResponseThinkingProps {
57
+ className?: string
58
+ }
59
+
60
+ export interface ResponseStepsProps {
61
+ className?: string
62
+ children?: ReactNode
63
+ }
64
+
65
+ export interface ResponseStepProps {
66
+ step: ResponseStep
67
+ status?: 'completed' | 'in-progress'
68
+ }
69
+
70
+ export interface ResponseContentProps {
71
+ /** Markdown 内容 */
72
+ markdown?: string
73
+ /** 是否流式渲染 */
74
+ streaming?: boolean
75
+ /** 流式渲染完成回调 */
76
+ onStreamComplete?: () => void
77
+ className?: string
78
+ }
79
+
80
+ export interface ResponseImageGeneratingProps {
81
+ width?: string
82
+ height?: string
83
+ className?: string
84
+ }
85
+
86
+ export interface ResponseDefaultLayoutProps {
87
+ className?: string
88
+ }
89
+
90
+ // ===== Context 类型 =====
91
+
92
+ export interface ResponseContextValue {
93
+ // 阶段控制
94
+ phase: ResponsePhase
95
+ simulate: boolean
96
+ // 数据
97
+ rounds: ResponseRound[]
98
+ finalMarkdown: string
99
+ blocks: ResponseBlock[]
100
+ // 状态
101
+ visibleIndex: number
102
+ setVisibleIndex: React.Dispatch<React.SetStateAction<number>>
103
+ isSimDone: boolean
104
+ currentBlock: ResponseBlock | null
105
+ showThink: boolean
106
+ // 回调
107
+ onSimulateComplete?: () => void
108
+ onStepChange?: (text: string, toolType?: ResponseStepToolType) => void
109
+ // 注入
110
+ thinkingIndicator: ReactNode
111
+ }
112
+
113
+ // ===== 复合组件类型 =====
114
+
115
+ export interface ResponseCompound {
116
+ Root: React.FC<ResponseRootProps>
117
+ Thinking: React.FC<ResponseThinkingProps>
118
+ Steps: React.FC<ResponseStepsProps>
119
+ Step: React.FC<ResponseStepProps>
120
+ Content: React.FC<ResponseContentProps>
121
+ ImageGenerating: React.FC<ResponseImageGeneratingProps>
122
+ DefaultLayout: React.FC<ResponseDefaultLayoutProps>
123
+ }
@@ -0,0 +1,94 @@
1
+ import { forwardRef } from 'react'
2
+ import type { ButtonHTMLAttributes, ReactNode } from 'react'
3
+ import { cva, type VariantProps } from 'class-variance-authority'
4
+ import { SendPlaneLine, StopLine, MicLine } from '../basic/icons-inline'
5
+
6
+ const sendButtonVariants = cva(
7
+ 'inline-flex items-center justify-center rounded-full p-1.5 transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition-transform active:scale-95 active:translate-y-px',
8
+ {
9
+ variants: {
10
+ status: {
11
+ normal:
12
+ 'SendButton-send bg-bg-highlight hover:bg-bg-highlight-hover active:opacity-80',
13
+ generating:
14
+ 'bg-primary hover:bg-primary-hover active:opacity-80',
15
+ recording:
16
+ 'SendButton-send bg-bg-highlight hover:bg-bg-highlight-hover active:opacity-80',
17
+ stopRecording:
18
+ 'bg-error-bg hover:bg-error-bg-hover active:opacity-80',
19
+ },
20
+ },
21
+ defaultVariants: {
22
+ status: 'normal',
23
+ },
24
+ }
25
+ )
26
+
27
+ const sendButtonIconVariants = cva('inline-flex h-4 w-4 shrink-0 items-center justify-center [&>*]:block [&>*]:leading-none', {
28
+ variants: {
29
+ status: {
30
+ normal: 'text-text-on-primary',
31
+ generating: 'text-text-on-primary',
32
+ recording: 'text-text-on-primary',
33
+ stopRecording: 'text-error',
34
+ },
35
+ },
36
+ defaultVariants: {
37
+ status: 'normal',
38
+ },
39
+ })
40
+
41
+ const iconClass = 'w-4 h-4'
42
+
43
+ export interface SendButtonProps
44
+ extends ButtonHTMLAttributes<HTMLButtonElement>,
45
+ VariantProps<typeof sendButtonVariants> {
46
+ status?: 'normal' | 'generating' | 'recording' | 'stopRecording'
47
+ /** 主库可注入 useIcon('send');不传则用 registry 内联图标 */
48
+ sendIcon?: ReactNode
49
+ stopIcon?: ReactNode
50
+ micIcon?: ReactNode
51
+ }
52
+
53
+ export const SendButton = forwardRef<HTMLButtonElement, SendButtonProps>(
54
+ (
55
+ {
56
+ status = 'normal',
57
+ disabled = false,
58
+ className,
59
+ sendIcon,
60
+ stopIcon,
61
+ micIcon,
62
+ ...props
63
+ },
64
+ ref
65
+ ) => {
66
+ const defaultSend = sendIcon ?? <SendPlaneLine className={iconClass} />
67
+ const defaultStop = stopIcon ?? <StopLine className={iconClass} />
68
+ const defaultMic = micIcon ?? <MicLine className={iconClass} />
69
+ const iconByStatus =
70
+ status === 'normal'
71
+ ? defaultSend
72
+ : status === 'generating' || status === 'stopRecording'
73
+ ? defaultStop
74
+ : defaultMic
75
+
76
+ return (
77
+ <button
78
+ ref={ref}
79
+ type="button"
80
+ className={sendButtonVariants({ status, className })}
81
+ disabled={disabled}
82
+ {...props}
83
+ >
84
+ <span className={sendButtonIconVariants({ status })}>
85
+ {iconByStatus}
86
+ </span>
87
+ </button>
88
+ )
89
+ }
90
+ )
91
+
92
+ SendButton.displayName = 'SendButton'
93
+
94
+ export { sendButtonVariants, sendButtonIconVariants }
@@ -0,0 +1,111 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { MarkdownBody } from './markdown'
3
+
4
+ function segmentForStreaming(text: string): string[] {
5
+ const tokens: string[] = []
6
+ let i = 0
7
+ const isCJK = (c: string) => /[\u4e00-\u9fff\u3040-\u30ff]/.test(c)
8
+ const isWordChar = (c: string) => /[a-zA-Z0-9']/.test(c)
9
+ while (i < text.length) {
10
+ const c = text[i]!
11
+ if (isCJK(c)) {
12
+ tokens.push(c)
13
+ i += 1
14
+ } else if (isWordChar(c)) {
15
+ let end = i
16
+ while (end < text.length && isWordChar(text[end]!)) end += 1
17
+ tokens.push(text.slice(i, end))
18
+ i = end
19
+ } else {
20
+ let end = i
21
+ while (end < text.length && !isCJK(text[end]!) && !isWordChar(text[end]!)) end += 1
22
+ tokens.push(text.slice(i, end))
23
+ i = end
24
+ }
25
+ }
26
+ return tokens
27
+ }
28
+
29
+ function splitCompletedBlocks(visibleText: string): { completed: string; partial: string } {
30
+ const blocks = visibleText.split(/\n\n+/)
31
+ if (blocks.length <= 1) return { completed: '', partial: visibleText }
32
+ const partial = blocks.pop() ?? ''
33
+ const completed = blocks.join('\n\n')
34
+ return { completed, partial }
35
+ }
36
+
37
+ export interface StreamingMarkdownBlockProps {
38
+ content: string
39
+ isActive: boolean
40
+ onStreamComplete?: () => void
41
+ tokenDelayMs?: number
42
+ className?: string
43
+ }
44
+
45
+ const TOKENS_PER_TICK = 3
46
+
47
+ export function StreamingMarkdownBlock({
48
+ content,
49
+ isActive,
50
+ onStreamComplete,
51
+ tokenDelayMs = 14,
52
+ className,
53
+ }: StreamingMarkdownBlockProps) {
54
+ const tokens = useMemo(() => segmentForStreaming(content), [content])
55
+ const [visibleCount, setVisibleCount] = useState(0)
56
+ const onStreamCompleteRef = useRef(onStreamComplete)
57
+ onStreamCompleteRef.current = onStreamComplete
58
+
59
+ const visibleText = useMemo(
60
+ () => tokens.slice(0, visibleCount).join(''),
61
+ [tokens, visibleCount]
62
+ )
63
+ const { completed, partial } = useMemo(() => splitCompletedBlocks(visibleText), [visibleText])
64
+
65
+ useEffect(() => {
66
+ if (!isActive) return
67
+ setVisibleCount(0)
68
+ const len = tokens.length
69
+ if (len === 0) {
70
+ onStreamCompleteRef.current?.()
71
+ return
72
+ }
73
+ let k = 0
74
+ const id = setInterval(() => {
75
+ k = Math.min(k + TOKENS_PER_TICK, len)
76
+ setVisibleCount(k)
77
+ if (k >= len) {
78
+ clearInterval(id)
79
+ onStreamCompleteRef.current?.()
80
+ }
81
+ }, tokenDelayMs)
82
+ return () => clearInterval(id)
83
+ }, [isActive, content, tokens.length, tokenDelayMs])
84
+
85
+ const isStreaming = isActive && visibleCount < tokens.length
86
+
87
+ if (tokens.length === 0) return null
88
+
89
+ if (isStreaming) {
90
+ return (
91
+ <div className={className}>
92
+ {completed ? (
93
+ <div className="streaming-completed">
94
+ <MarkdownBody>{completed}</MarkdownBody>
95
+ </div>
96
+ ) : null}
97
+ {partial ? (
98
+ <span className="streaming-partial text-sm leading-sm text-text streaming-token-fade-in">
99
+ {partial}
100
+ </span>
101
+ ) : null}
102
+ </div>
103
+ )
104
+ }
105
+
106
+ return (
107
+ <div className={className}>
108
+ <MarkdownBody>{content}</MarkdownBody>
109
+ </div>
110
+ )
111
+ }
@@ -0,0 +1,109 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from '@/lib/utils'
3
+ import { CollapsibleCard } from '../basic/collapsible-card'
4
+ import { CatalogLine, CircleLine, CheckboxCircleLine, IndeterminateCircleLine, ArrowRightCircleFillLine } from '../basic/icons-inline'
5
+
6
+ const ICON_CLASS = 'w-[var(--font-size-sm)] h-[var(--font-size-sm)] shrink-0'
7
+
8
+ export type TaskStatus = 'completed' | 'uncompleted' | 'skipped' | 'cancelled'
9
+
10
+ export interface TaskPartTask {
11
+ id?: string
12
+ text: string
13
+ status?: TaskStatus
14
+ }
15
+
16
+ export interface TaskPartProps {
17
+ tasks?: TaskPartTask[]
18
+ title?: string
19
+ currentTaskId?: string
20
+ defaultExpanded?: boolean
21
+ className?: string
22
+ headerIcon?: ReactNode
23
+ uncompletedIcon?: ReactNode
24
+ completedIcon?: ReactNode
25
+ skippedIcon?: ReactNode
26
+ loaderIcon?: ReactNode
27
+ }
28
+
29
+ const defaultHeaderIcon = <CatalogLine className={cn(ICON_CLASS, 'text-text-secondary')} />
30
+ const defaultUncompleted = <CircleLine className={cn(ICON_CLASS, 'text-text-tertiary')} />
31
+ const defaultCompleted = <CheckboxCircleLine className={cn(ICON_CLASS, 'text-text-quaternary')} />
32
+ const defaultSkipped = <IndeterminateCircleLine className={cn(ICON_CLASS, 'text-text-tertiary')} />
33
+ const defaultLoader = <ArrowRightCircleFillLine className={cn(ICON_CLASS, 'text-text')} />
34
+
35
+ function getStatusIcon(
36
+ task: TaskPartTask,
37
+ isCurrent: boolean,
38
+ icons: {
39
+ loaderIcon?: ReactNode
40
+ completedIcon?: ReactNode
41
+ skippedIcon?: ReactNode
42
+ uncompletedIcon?: ReactNode
43
+ }
44
+ ): ReactNode {
45
+ if (isCurrent) return icons.loaderIcon ?? defaultLoader
46
+ if (task.status === 'completed') return icons.completedIcon ?? defaultCompleted
47
+ if (task.status === 'skipped' || task.status === 'cancelled') return icons.skippedIcon ?? defaultSkipped
48
+ return icons.uncompletedIcon ?? defaultUncompleted
49
+ }
50
+
51
+ export function TaskPart({
52
+ tasks = [],
53
+ title = 'To-dos',
54
+ currentTaskId,
55
+ defaultExpanded = true,
56
+ className,
57
+ headerIcon,
58
+ uncompletedIcon,
59
+ completedIcon,
60
+ skippedIcon,
61
+ loaderIcon,
62
+ }: TaskPartProps) {
63
+ return (
64
+ <CollapsibleCard
65
+ headerIcon={headerIcon ?? defaultHeaderIcon}
66
+ headerTitle={<span className="text-left">{title}</span>}
67
+ defaultExpanded={defaultExpanded}
68
+ contentPadding="var(--spacing-1\.5) var(--spacing-2)"
69
+ showExpandAllBar
70
+ maxHeight={124}
71
+ className={className}
72
+ >
73
+ <div className="flex flex-col gap-2">
74
+ {tasks.map((task, index) => {
75
+ const taskId = task.id ?? index
76
+ const isCurrent = currentTaskId != null && currentTaskId === String(taskId)
77
+ const statusIcon = getStatusIcon(task, isCurrent, {
78
+ loaderIcon,
79
+ completedIcon,
80
+ skippedIcon,
81
+ uncompletedIcon,
82
+ })
83
+
84
+ return (
85
+ <div key={taskId} className="flex flex-row gap-2 items-start">
86
+ <div className="flex items-center justify-center shrink-0 pt-0.5 [&>svg]:w-[var(--font-size-sm)] [&>svg]:h-[var(--font-size-sm)]">
87
+ {statusIcon}
88
+ </div>
89
+ <span
90
+ className={cn(
91
+ 'text-xs leading-xs flex-1 text-left',
92
+ isCurrent
93
+ ? 'text-text'
94
+ : task.status === 'completed'
95
+ ? 'text-text-quaternary line-through'
96
+ : 'text-text-tertiary'
97
+ )}
98
+ >
99
+ {task.text}
100
+ </span>
101
+ </div>
102
+ )
103
+ })}
104
+ </div>
105
+ </CollapsibleCard>
106
+ )
107
+ }
108
+
109
+ TaskPart.displayName = 'TaskPart'
@@ -0,0 +1,69 @@
1
+ import type { ReactNode } from 'react'
2
+ import { CollapsibleCard } from '../basic/collapsible-card'
3
+ import { IconButton } from '../basic/icon-button'
4
+ import { TerminalBoxLine, ExternalLinkLine } from '../basic/icons-inline'
5
+
6
+ const ICON_CLASS = 'w-[var(--font-size-sm)] h-[var(--font-size-sm)] shrink-0 text-text-secondary'
7
+
8
+ export interface TerminalCodeBlockPartProps {
9
+ command?: string
10
+ output?: string
11
+ autoRanCommand?: string
12
+ className?: string
13
+ headerIcon?: ReactNode
14
+ externalLinkIcon?: ReactNode
15
+ }
16
+
17
+ const defaultHeaderIcon = <TerminalBoxLine className={ICON_CLASS} />
18
+ const defaultExternalLinkIcon = <ExternalLinkLine className={ICON_CLASS} />
19
+
20
+ export function TerminalCodeBlockPart({
21
+ command,
22
+ output,
23
+ autoRanCommand,
24
+ className,
25
+ headerIcon,
26
+ externalLinkIcon,
27
+ }: TerminalCodeBlockPartProps) {
28
+ const displayCommand = autoRanCommand ?? (command != null ? `Auto-Ran command: ${command}` : 'Terminal')
29
+
30
+ return (
31
+ <CollapsibleCard
32
+ headerIcon={headerIcon ?? defaultHeaderIcon}
33
+ headerTitle={<span className="font-normal">{displayCommand}</span>}
34
+ headerRight={
35
+ <IconButton
36
+ variant="iconOnly"
37
+ size="sm"
38
+ icon={externalLinkIcon ?? defaultExternalLinkIcon}
39
+ aria-label="Open in terminal"
40
+ className="!min-h-0 !min-w-0 h-[var(--font-size-sm)] w-[var(--font-size-sm)] text-text-secondary hover:text-text [&_svg]:w-[var(--font-size-sm)] [&_svg]:h-[var(--font-size-sm)]"
41
+ />
42
+ }
43
+ contentPadding="0"
44
+ className={className}
45
+ >
46
+ <div className="flex flex-col gap-0">
47
+ {command != null && command !== '' && (
48
+ <div className="px-2 py-1.5">
49
+ <div className="flex items-start gap-2">
50
+ <span className="font-mono text-xs text-text shrink-0">$</span>
51
+ <pre className="m-0 flex-1 overflow-x-auto text-left font-mono text-xs leading-xs whitespace-pre text-text bg-transparent scrollbar-auto">
52
+ <code>{command}</code>
53
+ </pre>
54
+ </div>
55
+ </div>
56
+ )}
57
+ {output != null && output !== '' && (
58
+ <div className="px-2 py-1.5 overflow-x-auto text-left scrollbar-auto">
59
+ <pre className="m-0 p-0 font-mono text-xs leading-xs whitespace-pre-wrap break-all text-text-tertiary bg-transparent">
60
+ {output}
61
+ </pre>
62
+ </div>
63
+ )}
64
+ </div>
65
+ </CollapsibleCard>
66
+ )
67
+ }
68
+
69
+ TerminalCodeBlockPart.displayName = 'TerminalCodeBlockPart'
@@ -0,0 +1,91 @@
1
+ import { useMemo } from 'react'
2
+ import type { HTMLAttributes, ReactNode } from 'react'
3
+ import { cn } from '@/lib/utils'
4
+ import { motion } from 'framer-motion'
5
+
6
+ export interface ThinkingIndicatorProps extends HTMLAttributes<HTMLDivElement> {
7
+ text?: string
8
+ textClassName?: string
9
+ shimmer?: boolean
10
+ shimmerDuration?: number
11
+ shimmerSpread?: number
12
+ /** 主库可注入 Lottie 等自定义动画;不传则用 CSS 三点脉动 */
13
+ loader?: ReactNode
14
+ }
15
+
16
+ /** 默认:CSS 三点脉动,无 Lottie 依赖 */
17
+ function DefaultLoader() {
18
+ return (
19
+ <span className="relative block w-5 h-5 shrink-0 flex items-center justify-center" aria-hidden>
20
+ <style>{`
21
+ .thinking-dots span { animation: thinking-dot 1.4s ease-in-out infinite both; }
22
+ .thinking-dots span:nth-child(1) { animation-delay: 0s; }
23
+ .thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
24
+ .thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
25
+ @keyframes thinking-dot {
26
+ 0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
27
+ 40% { transform: scale(1); opacity: 1; }
28
+ }
29
+ `}</style>
30
+ <span className="thinking-dots inline-flex gap-0.5">
31
+ <span className="w-1 h-1 rounded-full bg-current opacity-70" />
32
+ <span className="w-1 h-1 rounded-full bg-current opacity-70" />
33
+ <span className="w-1 h-1 rounded-full bg-current opacity-70" />
34
+ </span>
35
+ </span>
36
+ )
37
+ }
38
+
39
+ export function ThinkingIndicator({
40
+ text = 'Qoder is Thinking...',
41
+ textClassName,
42
+ shimmer = true,
43
+ shimmerDuration = 2,
44
+ shimmerSpread = 2,
45
+ className,
46
+ loader,
47
+ ...props
48
+ }: ThinkingIndicatorProps) {
49
+ const dynamicSpread = useMemo(() => {
50
+ const textToUse = text || ''
51
+ return textToUse.length * shimmerSpread
52
+ }, [text, shimmerSpread])
53
+
54
+ const loaderContent = loader ?? <DefaultLoader />
55
+
56
+ return (
57
+ <div
58
+ className={cn('inline-flex items-center gap-1.5 max-w-full', className)}
59
+ {...props}
60
+ >
61
+ <span className="shrink-0 -translate-x-0.5" aria-hidden>
62
+ {loaderContent}
63
+ </span>
64
+ <div className="flex-1 min-w-0 flex items-center">
65
+ {shimmer ? (
66
+ <motion.span
67
+ className={cn(
68
+ 'text-sm leading-sm relative inline-block bg-clip-text text-transparent',
69
+ 'bg-[length:250%_100%,auto] bg-no-repeat truncate',
70
+ textClassName
71
+ )}
72
+ style={{
73
+ backgroundImage: `linear-gradient(90deg, transparent calc(50% - ${dynamicSpread}px), var(--color-text), transparent calc(50% + ${dynamicSpread}px)), linear-gradient(var(--color-text-secondary), var(--color-text-secondary))`,
74
+ }}
75
+ initial={{ backgroundPosition: '100% center' }}
76
+ animate={{ backgroundPosition: '0% center' }}
77
+ transition={{ repeat: Infinity, duration: shimmerDuration, ease: 'linear' }}
78
+ >
79
+ {text}
80
+ </motion.span>
81
+ ) : (
82
+ <span className={cn('text-sm leading-sm text-text-secondary truncate', textClassName)}>
83
+ {text}
84
+ </span>
85
+ )}
86
+ </div>
87
+ </div>
88
+ )
89
+ }
90
+
91
+ ThinkingIndicator.displayName = 'ThinkingIndicator'