bingocode 1.0.2 → 1.0.3

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 (186) hide show
  1. package/desktop/README.md +30 -0
  2. package/desktop/bunfig.toml +1 -0
  3. package/desktop/index.html +17 -0
  4. package/desktop/package.json +55 -0
  5. package/desktop/pnpm-lock.yaml +3832 -0
  6. package/desktop/public/app-icon.jpg +0 -0
  7. package/desktop/public/fonts/inter-latin-ext.woff2 +0 -0
  8. package/desktop/public/fonts/inter-latin.woff2 +0 -0
  9. package/desktop/public/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  10. package/desktop/public/fonts/jetbrains-mono-latin.woff2 +0 -0
  11. package/desktop/public/fonts/manrope-latin-ext.woff2 +0 -0
  12. package/desktop/public/fonts/manrope-latin.woff2 +0 -0
  13. package/desktop/public/fonts/material-symbols-outlined.woff2 +0 -0
  14. package/desktop/public/icons/bilibili.svg +1 -0
  15. package/desktop/public/icons/douyin.svg +1 -0
  16. package/desktop/public/icons/github.svg +3 -0
  17. package/desktop/public/icons/xiaohongshu.svg +1 -0
  18. package/desktop/scripts/build-macos-arm64.sh +270 -0
  19. package/desktop/scripts/build-sidecars.ts +183 -0
  20. package/desktop/scripts/build-windows-x64.ps1 +295 -0
  21. package/desktop/scripts/scan-missing-imports.ts +235 -0
  22. package/desktop/sidecars/claude-sidecar.ts +156 -0
  23. package/desktop/src/App.tsx +5 -0
  24. package/desktop/src/__tests__/agentsSettings.test.tsx +349 -0
  25. package/desktop/src/__tests__/pages.test.tsx +290 -0
  26. package/desktop/src/__tests__/skillsSettings.test.tsx +205 -0
  27. package/desktop/src/api/adapters.ts +12 -0
  28. package/desktop/src/api/agents.ts +36 -0
  29. package/desktop/src/api/cliTasks.ts +28 -0
  30. package/desktop/src/api/client.ts +63 -0
  31. package/desktop/src/api/computerUse.ts +76 -0
  32. package/desktop/src/api/filesystem.ts +30 -0
  33. package/desktop/src/api/hahaOAuth.ts +38 -0
  34. package/desktop/src/api/models.ts +28 -0
  35. package/desktop/src/api/providers.ts +63 -0
  36. package/desktop/src/api/search.ts +29 -0
  37. package/desktop/src/api/sessions.ts +56 -0
  38. package/desktop/src/api/settings.ts +20 -0
  39. package/desktop/src/api/skills.ts +19 -0
  40. package/desktop/src/api/tasks.ts +36 -0
  41. package/desktop/src/api/teams.ts +44 -0
  42. package/desktop/src/api/websocket.ts +164 -0
  43. package/desktop/src/components/chat/AskUserQuestion.tsx +268 -0
  44. package/desktop/src/components/chat/AssistantMessage.tsx +29 -0
  45. package/desktop/src/components/chat/AttachmentGallery.tsx +113 -0
  46. package/desktop/src/components/chat/ChatInput.tsx +622 -0
  47. package/desktop/src/components/chat/CodeViewer.tsx +161 -0
  48. package/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx +174 -0
  49. package/desktop/src/components/chat/ComputerUsePermissionModal.tsx +311 -0
  50. package/desktop/src/components/chat/DiffViewer.tsx +157 -0
  51. package/desktop/src/components/chat/FileSearchMenu.tsx +198 -0
  52. package/desktop/src/components/chat/ImageGalleryModal.tsx +91 -0
  53. package/desktop/src/components/chat/InlineImageGallery.tsx +106 -0
  54. package/desktop/src/components/chat/InlineTaskSummary.tsx +60 -0
  55. package/desktop/src/components/chat/MermaidRenderer.test.tsx +98 -0
  56. package/desktop/src/components/chat/MermaidRenderer.tsx +361 -0
  57. package/desktop/src/components/chat/MessageActionBar.tsx +27 -0
  58. package/desktop/src/components/chat/MessageList.test.tsx +313 -0
  59. package/desktop/src/components/chat/MessageList.tsx +249 -0
  60. package/desktop/src/components/chat/PermissionDialog.tsx +262 -0
  61. package/desktop/src/components/chat/SessionTaskBar.test.tsx +99 -0
  62. package/desktop/src/components/chat/SessionTaskBar.tsx +159 -0
  63. package/desktop/src/components/chat/StreamingIndicator.tsx +41 -0
  64. package/desktop/src/components/chat/TerminalChrome.tsx +35 -0
  65. package/desktop/src/components/chat/ThinkingBlock.tsx +87 -0
  66. package/desktop/src/components/chat/ToolCallBlock.tsx +247 -0
  67. package/desktop/src/components/chat/ToolCallGroup.tsx +617 -0
  68. package/desktop/src/components/chat/ToolResultBlock.tsx +107 -0
  69. package/desktop/src/components/chat/UserMessage.tsx +38 -0
  70. package/desktop/src/components/chat/chatBlocks.test.tsx +136 -0
  71. package/desktop/src/components/chat/clipboard.ts +25 -0
  72. package/desktop/src/components/chat/composerUtils.test.ts +55 -0
  73. package/desktop/src/components/chat/composerUtils.ts +149 -0
  74. package/desktop/src/components/controls/ModelSelector.tsx +156 -0
  75. package/desktop/src/components/controls/PermissionModeSelector.tsx +229 -0
  76. package/desktop/src/components/layout/AppShell.tsx +107 -0
  77. package/desktop/src/components/layout/ContentRouter.tsx +27 -0
  78. package/desktop/src/components/layout/ProjectFilter.tsx +126 -0
  79. package/desktop/src/components/layout/Sidebar.test.tsx +158 -0
  80. package/desktop/src/components/layout/Sidebar.tsx +384 -0
  81. package/desktop/src/components/layout/StatusBar.tsx +31 -0
  82. package/desktop/src/components/layout/TabBar.test.tsx +136 -0
  83. package/desktop/src/components/layout/TabBar.tsx +318 -0
  84. package/desktop/src/components/layout/TitleBar.tsx +96 -0
  85. package/desktop/src/components/layout/WindowControls.test.tsx +69 -0
  86. package/desktop/src/components/layout/WindowControls.tsx +89 -0
  87. package/desktop/src/components/markdown/MarkdownRenderer.test.tsx +100 -0
  88. package/desktop/src/components/markdown/MarkdownRenderer.tsx +229 -0
  89. package/desktop/src/components/settings/ClaudeOfficialLogin.tsx +107 -0
  90. package/desktop/src/components/shared/Button.tsx +63 -0
  91. package/desktop/src/components/shared/CopyButton.tsx +58 -0
  92. package/desktop/src/components/shared/DirectoryPicker.tsx +316 -0
  93. package/desktop/src/components/shared/Dropdown.tsx +91 -0
  94. package/desktop/src/components/shared/Input.tsx +38 -0
  95. package/desktop/src/components/shared/Modal.tsx +65 -0
  96. package/desktop/src/components/shared/ProjectContextChip.tsx +30 -0
  97. package/desktop/src/components/shared/Spinner.tsx +30 -0
  98. package/desktop/src/components/shared/Textarea.tsx +38 -0
  99. package/desktop/src/components/shared/Toast.tsx +47 -0
  100. package/desktop/src/components/shared/UpdateChecker.tsx +90 -0
  101. package/desktop/src/components/skills/SkillDetail.test.tsx +89 -0
  102. package/desktop/src/components/skills/SkillDetail.tsx +403 -0
  103. package/desktop/src/components/skills/SkillList.tsx +254 -0
  104. package/desktop/src/components/tasks/DayOfWeekPicker.tsx +57 -0
  105. package/desktop/src/components/tasks/NewTaskModal.tsx +407 -0
  106. package/desktop/src/components/tasks/PromptEditor.tsx +74 -0
  107. package/desktop/src/components/tasks/TaskEmptyState.tsx +30 -0
  108. package/desktop/src/components/tasks/TaskList.tsx +46 -0
  109. package/desktop/src/components/tasks/TaskRow.tsx +253 -0
  110. package/desktop/src/components/tasks/TaskRunsPanel.tsx +195 -0
  111. package/desktop/src/components/teams/TeamStatusBar.tsx +147 -0
  112. package/desktop/src/config/providerPresets.ts +78 -0
  113. package/desktop/src/config/spinnerVerbs.ts +193 -0
  114. package/desktop/src/hooks/useKeyboardShortcuts.ts +60 -0
  115. package/desktop/src/i18n/index.ts +54 -0
  116. package/desktop/src/i18n/locales/en.ts +670 -0
  117. package/desktop/src/i18n/locales/zh.ts +670 -0
  118. package/desktop/src/lib/__tests__/cronDescribe.test.ts +93 -0
  119. package/desktop/src/lib/cronDescribe.ts +188 -0
  120. package/desktop/src/lib/desktopRuntime.ts +54 -0
  121. package/desktop/src/lib/parseRunOutput.ts +79 -0
  122. package/desktop/src/main.tsx +13 -0
  123. package/desktop/src/mocks/data.ts +202 -0
  124. package/desktop/src/pages/ActiveSession.test.tsx +181 -0
  125. package/desktop/src/pages/ActiveSession.tsx +219 -0
  126. package/desktop/src/pages/AdapterSettings.tsx +375 -0
  127. package/desktop/src/pages/AgentTeams.tsx +200 -0
  128. package/desktop/src/pages/ComputerUseSettings.tsx +420 -0
  129. package/desktop/src/pages/EmptySession.tsx +518 -0
  130. package/desktop/src/pages/NewTaskModal.tsx +346 -0
  131. package/desktop/src/pages/ScheduledTasks.tsx +66 -0
  132. package/desktop/src/pages/ScheduledTasksEmpty.tsx +152 -0
  133. package/desktop/src/pages/ScheduledTasksList.tsx +416 -0
  134. package/desktop/src/pages/SessionControls.tsx +460 -0
  135. package/desktop/src/pages/Settings.tsx +1448 -0
  136. package/desktop/src/pages/ToolInspection.tsx +235 -0
  137. package/desktop/src/stores/adapterStore.ts +106 -0
  138. package/desktop/src/stores/agentStore.ts +34 -0
  139. package/desktop/src/stores/chatStore.test.ts +505 -0
  140. package/desktop/src/stores/chatStore.ts +850 -0
  141. package/desktop/src/stores/cliTaskStore.ts +152 -0
  142. package/desktop/src/stores/hahaOAuthStore.test.ts +77 -0
  143. package/desktop/src/stores/hahaOAuthStore.ts +97 -0
  144. package/desktop/src/stores/providerStore.ts +101 -0
  145. package/desktop/src/stores/sessionStore.test.ts +63 -0
  146. package/desktop/src/stores/sessionStore.ts +102 -0
  147. package/desktop/src/stores/settingsStore.ts +120 -0
  148. package/desktop/src/stores/skillStore.ts +51 -0
  149. package/desktop/src/stores/tabStore.ts +169 -0
  150. package/desktop/src/stores/taskStore.ts +68 -0
  151. package/desktop/src/stores/teamStore.ts +344 -0
  152. package/desktop/src/stores/uiStore.ts +100 -0
  153. package/desktop/src/stores/updateStore.test.ts +71 -0
  154. package/desktop/src/stores/updateStore.ts +221 -0
  155. package/desktop/src/theme/globals.css +465 -0
  156. package/desktop/src/types/adapter.ts +33 -0
  157. package/desktop/src/types/chat.ts +152 -0
  158. package/desktop/src/types/cliTask.ts +24 -0
  159. package/desktop/src/types/provider.ts +62 -0
  160. package/desktop/src/types/session.ts +27 -0
  161. package/desktop/src/types/settings.ts +22 -0
  162. package/desktop/src/types/skill.ts +38 -0
  163. package/desktop/src/types/task.ts +56 -0
  164. package/desktop/src/types/team.ts +38 -0
  165. package/desktop/src-tauri/Cargo.lock +5549 -0
  166. package/desktop/src-tauri/Cargo.toml +20 -0
  167. package/desktop/src-tauri/app-icon.svg +13 -0
  168. package/desktop/src-tauri/build.rs +3 -0
  169. package/desktop/src-tauri/capabilities/default.json +106 -0
  170. package/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  171. package/desktop/src-tauri/icons/android/values/ic_launcher_background.xml +4 -0
  172. package/desktop/src-tauri/icons/icon.icns +0 -0
  173. package/desktop/src-tauri/icons/icon.ico +0 -0
  174. package/desktop/src-tauri/src/lib.rs +408 -0
  175. package/desktop/src-tauri/src/main.rs +6 -0
  176. package/desktop/src-tauri/tauri.conf.json +78 -0
  177. package/desktop/src-tauri/tauri.macos.conf.json +18 -0
  178. package/desktop/src-tauri/tauri.release-ci.json +5 -0
  179. package/desktop/src-tauri/tauri.windows.conf.json +16 -0
  180. package/desktop/src-tauri/windows-installer-hooks.nsh +17 -0
  181. package/desktop/tsconfig.json +25 -0
  182. package/desktop/vite.config.ts +26 -0
  183. package/desktop/vitest.config.ts +18 -0
  184. package/package.json +1 -1
  185. package/src/commands/desktop/desktop.tsx +9 -0
  186. package/src/commands/desktop/index.ts +26 -0
@@ -0,0 +1,107 @@
1
+ import { CodeViewer } from './CodeViewer'
2
+ import { useState } from 'react'
3
+ import { useTranslation } from '../../i18n'
4
+ import { InlineImageGallery } from './InlineImageGallery'
5
+
6
+ type Props = {
7
+ content: unknown
8
+ isError: boolean
9
+ toolName?: string
10
+ standalone?: boolean
11
+ }
12
+
13
+ /**
14
+ * Standalone tool result block — only shown when not already rendered
15
+ * inline within ToolCallBlock (i.e., when the tool_use and tool_result
16
+ * are NOT grouped together by MessageList).
17
+ */
18
+ export function ToolResultBlock({ content, isError, toolName, standalone = true }: Props) {
19
+ const [expanded, setExpanded] = useState(false)
20
+ const t = useTranslation()
21
+
22
+ // Don't render standalone if this result is already rendered inline
23
+ if (!standalone) return null
24
+
25
+ const text = extractText(content)
26
+ const preview = text.slice(0, 200)
27
+ const hasMore = text.length > 200
28
+
29
+ return (
30
+ <div className={`mb-2 ml-10 overflow-hidden rounded-xl border ${
31
+ isError
32
+ ? 'border-[var(--color-error)]/20'
33
+ : 'border-[var(--color-outline-variant)]/20'
34
+ }`}>
35
+ {/* Status header */}
36
+ <button
37
+ type="button"
38
+ onClick={() => setExpanded((value) => !value)}
39
+ className={`flex w-full items-center justify-between px-3 py-2 text-left text-[10px] font-bold uppercase tracking-wider ${
40
+ isError
41
+ ? 'bg-[var(--color-error-container)] text-[var(--color-error)]'
42
+ : 'bg-[var(--color-surface-container-high)] text-[var(--color-outline)]'
43
+ }`}
44
+ >
45
+ <span className="flex items-center gap-1.5">
46
+ <span className="material-symbols-outlined text-[12px]">
47
+ {isError ? 'error' : 'check_circle'}
48
+ </span>
49
+ {toolName ? t('tool.result', { toolName }) : t('tool.resultGeneric')}
50
+ </span>
51
+ <span className={`px-2 py-0.5 rounded-full text-[9px] ${
52
+ isError
53
+ ? 'bg-[var(--color-error)]/10'
54
+ : 'bg-[var(--color-diff-added-bg)] text-[var(--color-diff-added-text)]'
55
+ }`}>
56
+ {isError ? t('tool.error') : t('tool.success')}
57
+ </span>
58
+ </button>
59
+
60
+ {/* Inline image gallery from detected paths */}
61
+ <InlineImageGallery text={text} />
62
+
63
+ {/* Content */}
64
+ {expanded ? (
65
+ isError ? (
66
+ <div className="bg-[var(--color-error-container)]/50 px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.5] whitespace-pre-wrap break-words text-[var(--color-error)]">
67
+ {text}
68
+ </div>
69
+ ) : (
70
+ <CodeViewer
71
+ code={text}
72
+ language="plaintext"
73
+ maxLines={12}
74
+ />
75
+ )
76
+ ) : (
77
+ <div className="bg-[var(--color-surface-container-lowest)] px-3 py-2 font-[var(--font-mono)] text-[10px] leading-[1.35] text-[var(--color-text-tertiary)]">
78
+ {preview}
79
+ {hasMore ? '…' : ''}
80
+ </div>
81
+ )}
82
+
83
+ {hasMore && (
84
+ <button
85
+ onClick={() => setExpanded((value) => !value)}
86
+ className="w-full py-1 text-[10px] font-medium text-[var(--color-text-accent)] hover:underline bg-[var(--color-surface-container-low)] border-t border-[var(--color-outline-variant)]/10"
87
+ >
88
+ {expanded ? t('tool.showLess') : t('tool.showMore', { count: text.length - 200 })}
89
+ </button>
90
+ )}
91
+ </div>
92
+ )
93
+ }
94
+
95
+ function extractText(content: unknown): string {
96
+ if (typeof content === 'string') return content
97
+ if (Array.isArray(content)) {
98
+ return content
99
+ .map((c: any) => (typeof c === 'string' ? c : c?.text || ''))
100
+ .filter(Boolean)
101
+ .join('\n')
102
+ }
103
+ if (content && typeof content === 'object') {
104
+ return JSON.stringify(content, null, 2)
105
+ }
106
+ return String(content ?? '')
107
+ }
@@ -0,0 +1,38 @@
1
+ import type { UIAttachment } from '../../types/chat'
2
+ import { AttachmentGallery } from './AttachmentGallery'
3
+ import { MessageActionBar } from './MessageActionBar'
4
+
5
+ type Props = {
6
+ content: string
7
+ attachments?: UIAttachment[]
8
+ }
9
+
10
+ export function UserMessage({ content, attachments }: Props) {
11
+ const hasText = content.trim().length > 0
12
+
13
+ return (
14
+ <div className="group mb-5 flex items-end justify-end gap-1.5">
15
+ <div className="min-w-0 max-w-[82%] space-y-2">
16
+ {attachments && attachments.length > 0 && (
17
+ <AttachmentGallery attachments={attachments} variant="message" />
18
+ )}
19
+
20
+ {hasText && (
21
+ <div
22
+ className="bg-[var(--color-surface-user-msg)] px-4 py-3 text-sm leading-relaxed text-[var(--color-text-primary)] whitespace-pre-wrap break-words"
23
+ style={{ borderRadius: '18px 4px 18px 18px' }}
24
+ >
25
+ {content}
26
+ </div>
27
+ )}
28
+ </div>
29
+
30
+ {hasText && (
31
+ <MessageActionBar
32
+ copyText={content}
33
+ copyLabel="Copy prompt"
34
+ />
35
+ )}
36
+ </div>
37
+ )
38
+ }
@@ -0,0 +1,136 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+ import { ThinkingBlock } from './ThinkingBlock'
4
+ import { ToolCallBlock } from './ToolCallBlock'
5
+ import { PermissionDialog } from './PermissionDialog'
6
+ import { useChatStore } from '../../stores/chatStore'
7
+ import { useTabStore } from '../../stores/tabStore'
8
+
9
+ describe('chat blocks', () => {
10
+ beforeEach(() => {
11
+ useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] })
12
+ useChatStore.setState({ sessions: {} })
13
+ })
14
+
15
+ it('keeps thinking collapsed by default', () => {
16
+ const { container } = render(<ThinkingBlock content="this is a long internal reasoning trace" isActive />)
17
+
18
+ expect(screen.getByText(/Thinking/)).toBeTruthy()
19
+ expect(container.textContent).toContain('this is a long internal reasoning trace')
20
+ expect(container.querySelector('.thinking-cursor')).toBeNull()
21
+ })
22
+
23
+ it('does not animate inactive historical thinking blocks', () => {
24
+ const { container } = render(<ThinkingBlock content="old reasoning" isActive={false} />)
25
+
26
+ expect(container.querySelector('.thinking-inline-cursor')).toBeNull()
27
+ })
28
+
29
+ it('shows tool previews only after expanding the tool block', () => {
30
+ const { container } = render(
31
+ <ToolCallBlock
32
+ toolName="Read"
33
+ input={{ file_path: '/tmp/example.ts', limit: 20 }}
34
+ result={{ content: 'const answer = 42\nconsole.log(answer)', isError: false }}
35
+ />,
36
+ )
37
+
38
+ expect(container.textContent).toContain('Read')
39
+ expect(container.textContent).not.toContain('const answer = 42')
40
+
41
+ fireEvent.click(screen.getByRole('button'))
42
+
43
+ expect(container.textContent).toContain('Tool Input')
44
+ expect(container.textContent).not.toContain('const answer = 42')
45
+ })
46
+
47
+ it('does not surface bash stdout in the transcript preview', () => {
48
+ const { container } = render(
49
+ <ToolCallBlock
50
+ toolName="Bash"
51
+ input={{ command: 'ls -la', description: 'List files' }}
52
+ result={{ content: 'file-a\nfile-b\nfile-c', isError: false }}
53
+ />,
54
+ )
55
+
56
+ expect(container.textContent).toContain('Bash')
57
+ expect(container.textContent).not.toContain('file-a')
58
+
59
+ fireEvent.click(screen.getByRole('button'))
60
+
61
+ expect(container.textContent).toContain('ls -la')
62
+ expect(container.textContent).not.toContain('file-a')
63
+ })
64
+
65
+ it('expands tool errors so full Computer Use gate messages are readable', () => {
66
+ const { container } = render(
67
+ <ToolCallBlock
68
+ toolName="mcp__computer-use__left_click"
69
+ input={{ coordinate: [120, 220] }}
70
+ result={{
71
+ content: '"Claude Code Haha" is not in the allowed applications and is currently in front. Take a new screenshot — it may have appeared since your last one.',
72
+ isError: true,
73
+ }}
74
+ />,
75
+ )
76
+
77
+ expect(container.textContent).toContain('mcp__computer-use__left_click')
78
+ expect(container.textContent).not.toContain('Take a new screenshot')
79
+
80
+ fireEvent.click(screen.getByRole('button'))
81
+
82
+ expect(container.textContent).toContain('Take a new screenshot')
83
+ expect(container.textContent).toContain('allowed applications')
84
+ })
85
+
86
+ it('shows a diff preview for edit permission requests', () => {
87
+ useChatStore.setState({
88
+ sessions: {
89
+ 'active-tab': {
90
+ messages: [],
91
+ chatState: 'idle',
92
+ connectionState: 'connected',
93
+ streamingText: '',
94
+ streamingToolInput: '',
95
+ activeToolUseId: null,
96
+ activeToolName: null,
97
+ activeThinkingId: null,
98
+ pendingPermission: {
99
+ requestId: 'perm-1',
100
+ toolName: 'Edit',
101
+ input: {
102
+ file_path: '/tmp/example.ts',
103
+ old_string: 'const count = 1',
104
+ new_string: 'const count = 2',
105
+ },
106
+ },
107
+ pendingComputerUsePermission: null,
108
+ tokenUsage: { input_tokens: 0, output_tokens: 0 },
109
+ elapsedSeconds: 0,
110
+ statusVerb: '',
111
+ slashCommands: [],
112
+ agentTaskNotifications: {},
113
+ elapsedTimer: null,
114
+ },
115
+ },
116
+ })
117
+
118
+ const { container } = render(
119
+ <PermissionDialog
120
+ requestId="perm-1"
121
+ toolName="Edit"
122
+ input={{
123
+ file_path: '/tmp/example.ts',
124
+ old_string: 'const count = 1',
125
+ new_string: 'const count = 2',
126
+ }}
127
+ />,
128
+ )
129
+
130
+ expect(container.textContent).toContain('/tmp/example.ts')
131
+ expect(container.textContent).toContain('Allow')
132
+ // react-diff-viewer-continued uses styled-components tables that don't
133
+ // fully render in jsdom, so we verify the DiffViewer wrapper is mounted
134
+ expect(container.querySelector('[class*="rounded-[var(--radius-lg)]"]')).toBeTruthy()
135
+ })
136
+ })
@@ -0,0 +1,25 @@
1
+ export async function copyTextToClipboard(text: string): Promise<boolean> {
2
+ try {
3
+ if (navigator.clipboard?.writeText) {
4
+ await navigator.clipboard.writeText(text)
5
+ return true
6
+ }
7
+ } catch {
8
+ // Fall through to legacy copy path.
9
+ }
10
+
11
+ try {
12
+ const textarea = document.createElement('textarea')
13
+ textarea.value = text
14
+ textarea.setAttribute('readonly', 'true')
15
+ textarea.style.position = 'fixed'
16
+ textarea.style.opacity = '0'
17
+ document.body.appendChild(textarea)
18
+ textarea.select()
19
+ const copied = document.execCommand('copy')
20
+ document.body.removeChild(textarea)
21
+ return copied
22
+ } catch {
23
+ return false
24
+ }
25
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ findSlashToken,
4
+ insertSlashTrigger,
5
+ mergeSlashCommands,
6
+ replaceSlashCommand,
7
+ } from './composerUtils'
8
+
9
+ describe('composerUtils', () => {
10
+ it('finds slash token without trailing space', () => {
11
+ expect(findSlashToken('/rev', 4)).toEqual({ start: 0, filter: 'rev' })
12
+ expect(findSlashToken('hello /rev', 10)).toEqual({ start: 6, filter: 'rev' })
13
+ })
14
+
15
+ it('does not treat slash followed by a space as an active token', () => {
16
+ expect(findSlashToken('/ review', 8)).toBeNull()
17
+ })
18
+
19
+ it('inserts a slash trigger without appending a trailing space', () => {
20
+ expect(insertSlashTrigger('', 0)).toEqual({ value: '/', cursorPos: 1 })
21
+ expect(insertSlashTrigger('hello', 5)).toEqual({ value: 'hello /', cursorPos: 7 })
22
+ })
23
+
24
+ it('replaces the current slash token with a command and one trailing separator', () => {
25
+ expect(replaceSlashCommand('/rev', 4, 'review')).toEqual({
26
+ value: '/review ',
27
+ cursorPos: 8,
28
+ })
29
+ })
30
+
31
+ it('merges fallback commands so built-in entries like /clear remain visible', () => {
32
+ expect(
33
+ mergeSlashCommands([
34
+ { name: 'help', description: '' },
35
+ ]),
36
+ ).toEqual(
37
+ expect.arrayContaining([
38
+ { name: 'help', description: 'Show available commands' },
39
+ { name: 'clear', description: 'Clear conversation history' },
40
+ ]),
41
+ )
42
+ })
43
+
44
+ it('keeps server-provided descriptions when they exist', () => {
45
+ expect(
46
+ mergeSlashCommands([
47
+ { name: 'clear', description: 'Server description' },
48
+ ]),
49
+ ).toEqual(
50
+ expect.arrayContaining([
51
+ { name: 'clear', description: 'Server description' },
52
+ ]),
53
+ )
54
+ })
55
+ })
@@ -0,0 +1,149 @@
1
+ export const FALLBACK_SLASH_COMMANDS = [
2
+ { name: 'compact', description: 'Compact conversation context' },
3
+ { name: 'clear', description: 'Clear conversation history' },
4
+ { name: 'help', description: 'Show available commands' },
5
+ { name: 'review', description: 'Review code changes' },
6
+ { name: 'commit', description: 'Create a git commit' },
7
+ { name: 'pr', description: 'Create a pull request' },
8
+ { name: 'init', description: 'Initialize project CLAUDE.md' },
9
+ { name: 'bug', description: 'Report a bug' },
10
+ { name: 'config', description: 'Open configuration' },
11
+ { name: 'cost', description: 'Show token usage and costs' },
12
+ { name: 'doctor', description: 'Diagnose installation issues' },
13
+ { name: 'login', description: 'Switch Anthropic accounts' },
14
+ { name: 'logout', description: 'Sign out of current account' },
15
+ { name: 'memory', description: 'Edit CLAUDE.md memory files' },
16
+ { name: 'model', description: 'Switch AI model' },
17
+ { name: 'permissions', description: 'View or manage tool permissions' },
18
+ { name: 'status', description: 'Show project and session status' },
19
+ { name: 'terminal-setup', description: 'Set up terminal integration' },
20
+ { name: 'vim', description: 'Toggle vim editing mode' },
21
+ ]
22
+
23
+ export type SlashCommandOption = {
24
+ name: string
25
+ description: string
26
+ }
27
+
28
+ export function mergeSlashCommands(
29
+ preferred: ReadonlyArray<SlashCommandOption>,
30
+ fallback: ReadonlyArray<SlashCommandOption> = FALLBACK_SLASH_COMMANDS,
31
+ ): SlashCommandOption[] {
32
+ const merged = new Map<string, SlashCommandOption>()
33
+
34
+ for (const command of preferred) {
35
+ if (!command?.name) continue
36
+ merged.set(command.name, {
37
+ name: command.name,
38
+ description: command.description?.trim() || '',
39
+ })
40
+ }
41
+
42
+ for (const command of fallback) {
43
+ if (!command?.name) continue
44
+ const existing = merged.get(command.name)
45
+ if (existing) {
46
+ if (!existing.description && command.description) {
47
+ merged.set(command.name, {
48
+ ...existing,
49
+ description: command.description,
50
+ })
51
+ }
52
+ continue
53
+ }
54
+ merged.set(command.name, command)
55
+ }
56
+
57
+ return [...merged.values()]
58
+ }
59
+
60
+ export type SlashTrigger = {
61
+ slashPos: number
62
+ filter: string
63
+ }
64
+
65
+ export function findSlashTrigger(value: string, cursorPos: number): SlashTrigger | null {
66
+ const textBeforeCursor = value.slice(0, cursorPos)
67
+ let slashPos = -1
68
+
69
+ for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
70
+ const ch = textBeforeCursor[i]!
71
+ if (ch === '/') {
72
+ if (i === 0 || /\s/.test(textBeforeCursor[i - 1]!)) {
73
+ slashPos = i
74
+ break
75
+ }
76
+ break
77
+ }
78
+ if (/\s/.test(ch)) {
79
+ break
80
+ }
81
+ }
82
+
83
+ if (slashPos < 0) return null
84
+
85
+ const filter = textBeforeCursor.slice(slashPos + 1)
86
+ if (/\s/.test(filter)) return null
87
+
88
+ return { slashPos, filter }
89
+ }
90
+
91
+ export function replaceSlashToken(
92
+ input: string,
93
+ cursorPos: number,
94
+ command: string,
95
+ options?: { trailingSpace?: boolean },
96
+ ): { value: string; cursorPos: number } {
97
+ const trigger = findSlashTrigger(input, cursorPos)
98
+ if (!trigger) {
99
+ const prefix = input && !/\s$/.test(input) ? `${input} ` : input
100
+ const token = `/${command}`
101
+ const suffix = options?.trailingSpace !== false ? ' ' : ''
102
+ const value = `${prefix}${token}${suffix}`
103
+ return { value, cursorPos: value.length }
104
+ }
105
+
106
+ const before = input.slice(0, trigger.slashPos)
107
+ const after = input.slice(cursorPos)
108
+ const token = `/${command}`
109
+ const suffix = options?.trailingSpace !== false ? ' ' : ''
110
+ const value = `${before}${token}${suffix}${after}`
111
+ const nextCursorPos = before.length + token.length + suffix.length
112
+ return { value, cursorPos: nextCursorPos }
113
+ }
114
+
115
+ export type SlashToken = {
116
+ start: number
117
+ filter: string
118
+ }
119
+
120
+ export function findSlashToken(value: string, cursorPos: number): SlashToken | null {
121
+ const trigger = findSlashTrigger(value, cursorPos)
122
+ if (!trigger) return null
123
+ return { start: trigger.slashPos, filter: trigger.filter }
124
+ }
125
+
126
+ export function replaceSlashCommand(
127
+ value: string,
128
+ cursorPos: number,
129
+ command: string,
130
+ ): { value: string; cursorPos: number } | null {
131
+ const trigger = findSlashTrigger(value, cursorPos)
132
+ if (!trigger) return null
133
+
134
+ return replaceSlashToken(value, cursorPos, command, { trailingSpace: true })
135
+ }
136
+
137
+ export function insertSlashTrigger(
138
+ value: string,
139
+ cursorPos: number,
140
+ ): { value: string; cursorPos: number } {
141
+ const before = value.slice(0, cursorPos)
142
+ const after = value.slice(cursorPos)
143
+ const needsLeadingSpace = before.length > 0 && !/\s$/.test(before)
144
+ const token = `${needsLeadingSpace ? ' ' : ''}/`
145
+ return {
146
+ value: `${before}${token}${after}`,
147
+ cursorPos: before.length + token.length,
148
+ }
149
+ }
@@ -0,0 +1,156 @@
1
+ import { useState, useRef, useEffect } from 'react'
2
+ import { useSettingsStore } from '../../stores/settingsStore'
3
+ import { useTranslation } from '../../i18n'
4
+ import type { EffortLevel } from '../../types/settings'
5
+
6
+ const MODEL_ICONS = {
7
+ opus: 'diamond',
8
+ sonnet: 'auto_awesome',
9
+ haiku: 'bolt',
10
+ } as const
11
+
12
+ type Props = {
13
+ /** Controlled mode: model ID override */
14
+ value?: string
15
+ /** Controlled mode: called on change instead of updating global store */
16
+ onChange?: (modelId: string) => void
17
+ }
18
+
19
+ export function ModelSelector({ value, onChange }: Props = {}) {
20
+ const t = useTranslation()
21
+ const { currentModel: storeModel, availableModels, effortLevel, setModel, setEffort } = useSettingsStore()
22
+ const [open, setOpen] = useState(false)
23
+ const ref = useRef<HTMLDivElement>(null)
24
+
25
+ const EFFORT_OPTIONS: { value: EffortLevel; label: string }[] = [
26
+ { value: 'low', label: t('settings.general.effort.low') },
27
+ { value: 'medium', label: t('settings.general.effort.medium') },
28
+ { value: 'high', label: t('settings.general.effort.high') },
29
+ { value: 'max', label: t('settings.general.effort.max') },
30
+ ]
31
+
32
+ const isControlled = value !== undefined
33
+ const selectedModel = isControlled ? availableModels.find((m) => m.id === value) || null : storeModel
34
+
35
+ useEffect(() => {
36
+ if (!open) return
37
+ const handleClick = (e: MouseEvent) => {
38
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
39
+ }
40
+ const handleEsc = (e: KeyboardEvent) => {
41
+ if (e.key === 'Escape') setOpen(false)
42
+ }
43
+ document.addEventListener('mousedown', handleClick)
44
+ document.addEventListener('keydown', handleEsc)
45
+ return () => {
46
+ document.removeEventListener('mousedown', handleClick)
47
+ document.removeEventListener('keydown', handleEsc)
48
+ }
49
+ }, [open])
50
+
51
+ const getModelIcon = (id: string): string => {
52
+ const lower = id.toLowerCase()
53
+ if (lower.includes('opus')) return MODEL_ICONS.opus
54
+ if (lower.includes('sonnet')) return MODEL_ICONS.sonnet
55
+ if (lower.includes('haiku')) return MODEL_ICONS.haiku
56
+ return 'smart_toy'
57
+ }
58
+
59
+ return (
60
+ <div ref={ref} className="relative">
61
+ <button
62
+ onClick={() => setOpen(!open)}
63
+ className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs font-medium text-[var(--color-text-secondary)] transition-colors"
64
+ >
65
+ <span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">auto_awesome</span>
66
+ <span>{selectedModel?.name ?? t('model.selectModel')}</span>
67
+ <span className="material-symbols-outlined text-[12px]">expand_more</span>
68
+ </button>
69
+
70
+ {open && (
71
+ <div className="absolute right-0 bottom-full mb-2 w-[340px] rounded-xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] z-50">
72
+ {/* Models */}
73
+ <div className="p-3">
74
+ <div className="text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)] mb-2 px-1">
75
+ {t('model.configuration')}
76
+ </div>
77
+ <div className="space-y-1">
78
+ {availableModels.map((model) => {
79
+ const isSelected = model.id === selectedModel?.id
80
+ return (
81
+ <button
82
+ key={model.id}
83
+ onClick={() => {
84
+ if (isControlled) {
85
+ onChange?.(model.id)
86
+ } else {
87
+ setModel(model.id)
88
+ }
89
+ setOpen(false)
90
+ }}
91
+ className={`
92
+ w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors
93
+ ${isSelected
94
+ ? 'bg-[var(--color-primary-fixed)] border border-[var(--color-brand)]/20'
95
+ : 'hover:bg-[var(--color-surface-hover)]'
96
+ }
97
+ `}
98
+ >
99
+ {/* Radio button */}
100
+ <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
101
+ isSelected
102
+ ? 'border-[var(--color-brand)]'
103
+ : 'border-[var(--color-outline)]'
104
+ }`}>
105
+ {isSelected && (
106
+ <div className="w-2 h-2 rounded-full bg-[var(--color-brand)]" />
107
+ )}
108
+ </div>
109
+
110
+ <span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">
111
+ {getModelIcon(model.id)}
112
+ </span>
113
+
114
+ <div className="flex-1 min-w-0">
115
+ <div className="text-sm font-semibold text-[var(--color-text-primary)]">{model.name}</div>
116
+ {model.description && (
117
+ <div className="text-[10px] text-[var(--color-text-tertiary)] mt-0.5 truncate">{model.description}</div>
118
+ )}
119
+ </div>
120
+ </button>
121
+ )
122
+ })}
123
+ </div>
124
+ </div>
125
+
126
+ {/* Effort — hidden in controlled mode (not relevant for task creation) */}
127
+ {!isControlled && <div className="border-t border-[var(--color-border)] p-3">
128
+ <div className="text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)] mb-2 px-1">
129
+ {t('model.effort')}
130
+ </div>
131
+ <div className="grid grid-cols-4 gap-1.5">
132
+ {EFFORT_OPTIONS.map((opt) => {
133
+ const isSelected = opt.value === effortLevel
134
+ return (
135
+ <button
136
+ key={opt.value}
137
+ onClick={() => { setEffort(opt.value); setOpen(false) }}
138
+ className={`
139
+ py-2 rounded-lg text-xs font-semibold transition-colors text-center
140
+ ${isSelected
141
+ ? 'bg-[var(--color-brand)] text-white'
142
+ : 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
143
+ }
144
+ `}
145
+ >
146
+ {opt.label}
147
+ </button>
148
+ )
149
+ })}
150
+ </div>
151
+ </div>}
152
+ </div>
153
+ )}
154
+ </div>
155
+ )
156
+ }