bingocode 1.0.1 → 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 (187) hide show
  1. package/bin/bingo-win.cjs +34 -3
  2. package/desktop/README.md +30 -0
  3. package/desktop/bunfig.toml +1 -0
  4. package/desktop/index.html +17 -0
  5. package/desktop/package.json +55 -0
  6. package/desktop/pnpm-lock.yaml +3832 -0
  7. package/desktop/public/app-icon.jpg +0 -0
  8. package/desktop/public/fonts/inter-latin-ext.woff2 +0 -0
  9. package/desktop/public/fonts/inter-latin.woff2 +0 -0
  10. package/desktop/public/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  11. package/desktop/public/fonts/jetbrains-mono-latin.woff2 +0 -0
  12. package/desktop/public/fonts/manrope-latin-ext.woff2 +0 -0
  13. package/desktop/public/fonts/manrope-latin.woff2 +0 -0
  14. package/desktop/public/fonts/material-symbols-outlined.woff2 +0 -0
  15. package/desktop/public/icons/bilibili.svg +1 -0
  16. package/desktop/public/icons/douyin.svg +1 -0
  17. package/desktop/public/icons/github.svg +3 -0
  18. package/desktop/public/icons/xiaohongshu.svg +1 -0
  19. package/desktop/scripts/build-macos-arm64.sh +270 -0
  20. package/desktop/scripts/build-sidecars.ts +183 -0
  21. package/desktop/scripts/build-windows-x64.ps1 +295 -0
  22. package/desktop/scripts/scan-missing-imports.ts +235 -0
  23. package/desktop/sidecars/claude-sidecar.ts +156 -0
  24. package/desktop/src/App.tsx +5 -0
  25. package/desktop/src/__tests__/agentsSettings.test.tsx +349 -0
  26. package/desktop/src/__tests__/pages.test.tsx +290 -0
  27. package/desktop/src/__tests__/skillsSettings.test.tsx +205 -0
  28. package/desktop/src/api/adapters.ts +12 -0
  29. package/desktop/src/api/agents.ts +36 -0
  30. package/desktop/src/api/cliTasks.ts +28 -0
  31. package/desktop/src/api/client.ts +63 -0
  32. package/desktop/src/api/computerUse.ts +76 -0
  33. package/desktop/src/api/filesystem.ts +30 -0
  34. package/desktop/src/api/hahaOAuth.ts +38 -0
  35. package/desktop/src/api/models.ts +28 -0
  36. package/desktop/src/api/providers.ts +63 -0
  37. package/desktop/src/api/search.ts +29 -0
  38. package/desktop/src/api/sessions.ts +56 -0
  39. package/desktop/src/api/settings.ts +20 -0
  40. package/desktop/src/api/skills.ts +19 -0
  41. package/desktop/src/api/tasks.ts +36 -0
  42. package/desktop/src/api/teams.ts +44 -0
  43. package/desktop/src/api/websocket.ts +164 -0
  44. package/desktop/src/components/chat/AskUserQuestion.tsx +268 -0
  45. package/desktop/src/components/chat/AssistantMessage.tsx +29 -0
  46. package/desktop/src/components/chat/AttachmentGallery.tsx +113 -0
  47. package/desktop/src/components/chat/ChatInput.tsx +622 -0
  48. package/desktop/src/components/chat/CodeViewer.tsx +161 -0
  49. package/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx +174 -0
  50. package/desktop/src/components/chat/ComputerUsePermissionModal.tsx +311 -0
  51. package/desktop/src/components/chat/DiffViewer.tsx +157 -0
  52. package/desktop/src/components/chat/FileSearchMenu.tsx +198 -0
  53. package/desktop/src/components/chat/ImageGalleryModal.tsx +91 -0
  54. package/desktop/src/components/chat/InlineImageGallery.tsx +106 -0
  55. package/desktop/src/components/chat/InlineTaskSummary.tsx +60 -0
  56. package/desktop/src/components/chat/MermaidRenderer.test.tsx +98 -0
  57. package/desktop/src/components/chat/MermaidRenderer.tsx +361 -0
  58. package/desktop/src/components/chat/MessageActionBar.tsx +27 -0
  59. package/desktop/src/components/chat/MessageList.test.tsx +313 -0
  60. package/desktop/src/components/chat/MessageList.tsx +249 -0
  61. package/desktop/src/components/chat/PermissionDialog.tsx +262 -0
  62. package/desktop/src/components/chat/SessionTaskBar.test.tsx +99 -0
  63. package/desktop/src/components/chat/SessionTaskBar.tsx +159 -0
  64. package/desktop/src/components/chat/StreamingIndicator.tsx +41 -0
  65. package/desktop/src/components/chat/TerminalChrome.tsx +35 -0
  66. package/desktop/src/components/chat/ThinkingBlock.tsx +87 -0
  67. package/desktop/src/components/chat/ToolCallBlock.tsx +247 -0
  68. package/desktop/src/components/chat/ToolCallGroup.tsx +617 -0
  69. package/desktop/src/components/chat/ToolResultBlock.tsx +107 -0
  70. package/desktop/src/components/chat/UserMessage.tsx +38 -0
  71. package/desktop/src/components/chat/chatBlocks.test.tsx +136 -0
  72. package/desktop/src/components/chat/clipboard.ts +25 -0
  73. package/desktop/src/components/chat/composerUtils.test.ts +55 -0
  74. package/desktop/src/components/chat/composerUtils.ts +149 -0
  75. package/desktop/src/components/controls/ModelSelector.tsx +156 -0
  76. package/desktop/src/components/controls/PermissionModeSelector.tsx +229 -0
  77. package/desktop/src/components/layout/AppShell.tsx +107 -0
  78. package/desktop/src/components/layout/ContentRouter.tsx +27 -0
  79. package/desktop/src/components/layout/ProjectFilter.tsx +126 -0
  80. package/desktop/src/components/layout/Sidebar.test.tsx +158 -0
  81. package/desktop/src/components/layout/Sidebar.tsx +384 -0
  82. package/desktop/src/components/layout/StatusBar.tsx +31 -0
  83. package/desktop/src/components/layout/TabBar.test.tsx +136 -0
  84. package/desktop/src/components/layout/TabBar.tsx +318 -0
  85. package/desktop/src/components/layout/TitleBar.tsx +96 -0
  86. package/desktop/src/components/layout/WindowControls.test.tsx +69 -0
  87. package/desktop/src/components/layout/WindowControls.tsx +89 -0
  88. package/desktop/src/components/markdown/MarkdownRenderer.test.tsx +100 -0
  89. package/desktop/src/components/markdown/MarkdownRenderer.tsx +229 -0
  90. package/desktop/src/components/settings/ClaudeOfficialLogin.tsx +107 -0
  91. package/desktop/src/components/shared/Button.tsx +63 -0
  92. package/desktop/src/components/shared/CopyButton.tsx +58 -0
  93. package/desktop/src/components/shared/DirectoryPicker.tsx +316 -0
  94. package/desktop/src/components/shared/Dropdown.tsx +91 -0
  95. package/desktop/src/components/shared/Input.tsx +38 -0
  96. package/desktop/src/components/shared/Modal.tsx +65 -0
  97. package/desktop/src/components/shared/ProjectContextChip.tsx +30 -0
  98. package/desktop/src/components/shared/Spinner.tsx +30 -0
  99. package/desktop/src/components/shared/Textarea.tsx +38 -0
  100. package/desktop/src/components/shared/Toast.tsx +47 -0
  101. package/desktop/src/components/shared/UpdateChecker.tsx +90 -0
  102. package/desktop/src/components/skills/SkillDetail.test.tsx +89 -0
  103. package/desktop/src/components/skills/SkillDetail.tsx +403 -0
  104. package/desktop/src/components/skills/SkillList.tsx +254 -0
  105. package/desktop/src/components/tasks/DayOfWeekPicker.tsx +57 -0
  106. package/desktop/src/components/tasks/NewTaskModal.tsx +407 -0
  107. package/desktop/src/components/tasks/PromptEditor.tsx +74 -0
  108. package/desktop/src/components/tasks/TaskEmptyState.tsx +30 -0
  109. package/desktop/src/components/tasks/TaskList.tsx +46 -0
  110. package/desktop/src/components/tasks/TaskRow.tsx +253 -0
  111. package/desktop/src/components/tasks/TaskRunsPanel.tsx +195 -0
  112. package/desktop/src/components/teams/TeamStatusBar.tsx +147 -0
  113. package/desktop/src/config/providerPresets.ts +78 -0
  114. package/desktop/src/config/spinnerVerbs.ts +193 -0
  115. package/desktop/src/hooks/useKeyboardShortcuts.ts +60 -0
  116. package/desktop/src/i18n/index.ts +54 -0
  117. package/desktop/src/i18n/locales/en.ts +670 -0
  118. package/desktop/src/i18n/locales/zh.ts +670 -0
  119. package/desktop/src/lib/__tests__/cronDescribe.test.ts +93 -0
  120. package/desktop/src/lib/cronDescribe.ts +188 -0
  121. package/desktop/src/lib/desktopRuntime.ts +54 -0
  122. package/desktop/src/lib/parseRunOutput.ts +79 -0
  123. package/desktop/src/main.tsx +13 -0
  124. package/desktop/src/mocks/data.ts +202 -0
  125. package/desktop/src/pages/ActiveSession.test.tsx +181 -0
  126. package/desktop/src/pages/ActiveSession.tsx +219 -0
  127. package/desktop/src/pages/AdapterSettings.tsx +375 -0
  128. package/desktop/src/pages/AgentTeams.tsx +200 -0
  129. package/desktop/src/pages/ComputerUseSettings.tsx +420 -0
  130. package/desktop/src/pages/EmptySession.tsx +518 -0
  131. package/desktop/src/pages/NewTaskModal.tsx +346 -0
  132. package/desktop/src/pages/ScheduledTasks.tsx +66 -0
  133. package/desktop/src/pages/ScheduledTasksEmpty.tsx +152 -0
  134. package/desktop/src/pages/ScheduledTasksList.tsx +416 -0
  135. package/desktop/src/pages/SessionControls.tsx +460 -0
  136. package/desktop/src/pages/Settings.tsx +1448 -0
  137. package/desktop/src/pages/ToolInspection.tsx +235 -0
  138. package/desktop/src/stores/adapterStore.ts +106 -0
  139. package/desktop/src/stores/agentStore.ts +34 -0
  140. package/desktop/src/stores/chatStore.test.ts +505 -0
  141. package/desktop/src/stores/chatStore.ts +850 -0
  142. package/desktop/src/stores/cliTaskStore.ts +152 -0
  143. package/desktop/src/stores/hahaOAuthStore.test.ts +77 -0
  144. package/desktop/src/stores/hahaOAuthStore.ts +97 -0
  145. package/desktop/src/stores/providerStore.ts +101 -0
  146. package/desktop/src/stores/sessionStore.test.ts +63 -0
  147. package/desktop/src/stores/sessionStore.ts +102 -0
  148. package/desktop/src/stores/settingsStore.ts +120 -0
  149. package/desktop/src/stores/skillStore.ts +51 -0
  150. package/desktop/src/stores/tabStore.ts +169 -0
  151. package/desktop/src/stores/taskStore.ts +68 -0
  152. package/desktop/src/stores/teamStore.ts +344 -0
  153. package/desktop/src/stores/uiStore.ts +100 -0
  154. package/desktop/src/stores/updateStore.test.ts +71 -0
  155. package/desktop/src/stores/updateStore.ts +221 -0
  156. package/desktop/src/theme/globals.css +465 -0
  157. package/desktop/src/types/adapter.ts +33 -0
  158. package/desktop/src/types/chat.ts +152 -0
  159. package/desktop/src/types/cliTask.ts +24 -0
  160. package/desktop/src/types/provider.ts +62 -0
  161. package/desktop/src/types/session.ts +27 -0
  162. package/desktop/src/types/settings.ts +22 -0
  163. package/desktop/src/types/skill.ts +38 -0
  164. package/desktop/src/types/task.ts +56 -0
  165. package/desktop/src/types/team.ts +38 -0
  166. package/desktop/src-tauri/Cargo.lock +5549 -0
  167. package/desktop/src-tauri/Cargo.toml +20 -0
  168. package/desktop/src-tauri/app-icon.svg +13 -0
  169. package/desktop/src-tauri/build.rs +3 -0
  170. package/desktop/src-tauri/capabilities/default.json +106 -0
  171. package/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  172. package/desktop/src-tauri/icons/android/values/ic_launcher_background.xml +4 -0
  173. package/desktop/src-tauri/icons/icon.icns +0 -0
  174. package/desktop/src-tauri/icons/icon.ico +0 -0
  175. package/desktop/src-tauri/src/lib.rs +408 -0
  176. package/desktop/src-tauri/src/main.rs +6 -0
  177. package/desktop/src-tauri/tauri.conf.json +78 -0
  178. package/desktop/src-tauri/tauri.macos.conf.json +18 -0
  179. package/desktop/src-tauri/tauri.release-ci.json +5 -0
  180. package/desktop/src-tauri/tauri.windows.conf.json +16 -0
  181. package/desktop/src-tauri/windows-installer-hooks.nsh +17 -0
  182. package/desktop/tsconfig.json +25 -0
  183. package/desktop/vite.config.ts +26 -0
  184. package/desktop/vitest.config.ts +18 -0
  185. package/package.json +1 -1
  186. package/src/commands/desktop/desktop.tsx +9 -0
  187. package/src/commands/desktop/index.ts +26 -0
@@ -0,0 +1,262 @@
1
+ import { useState } from 'react'
2
+ import { useChatStore } from '../../stores/chatStore'
3
+ import { useTabStore } from '../../stores/tabStore'
4
+ import { useTranslation } from '../../i18n'
5
+ import type { TranslationKey } from '../../i18n'
6
+ import { Button } from '../shared/Button'
7
+ import { DiffViewer } from './DiffViewer'
8
+
9
+ type Props = {
10
+ requestId: string
11
+ toolName: string
12
+ input: unknown
13
+ description?: string
14
+ }
15
+
16
+ /**
17
+ * Icons for known tool types.
18
+ * Uses Material Symbols Outlined names.
19
+ */
20
+ const TOOL_META: Record<string, { icon: string; label: string; color: string }> = {
21
+ Bash: { icon: 'terminal', label: 'Bash', color: 'var(--color-warning)' },
22
+ Edit: { icon: 'edit_note', label: 'Edit File', color: 'var(--color-brand)' },
23
+ Write: { icon: 'edit_document', label: 'Write File', color: 'var(--color-success)' },
24
+ Read: { icon: 'description', label: 'Read File', color: 'var(--color-secondary)' },
25
+ Glob: { icon: 'search', label: 'Glob Search', color: 'var(--color-secondary)' },
26
+ Grep: { icon: 'find_in_page', label: 'Grep Search', color: 'var(--color-secondary)' },
27
+ Agent: { icon: 'smart_toy', label: 'Agent', color: 'var(--color-tertiary)' },
28
+ WebSearch: { icon: 'travel_explore', label: 'Web Search', color: 'var(--color-secondary)' },
29
+ WebFetch: { icon: 'cloud_download', label: 'Web Fetch', color: 'var(--color-secondary)' },
30
+ NotebookEdit: { icon: 'note', label: 'Notebook Edit', color: 'var(--color-brand)' },
31
+ Skill: { icon: 'auto_awesome', label: 'Skill', color: 'var(--color-tertiary)' },
32
+ }
33
+
34
+ /**
35
+ * Extract human-readable detail lines from tool input.
36
+ */
37
+ function extractToolDetails(toolName: string, input: unknown, t: (key: TranslationKey, params?: Record<string, string | number>) => string): { primary: string; secondary?: string } {
38
+ const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
39
+
40
+ switch (toolName) {
41
+ case 'Bash': {
42
+ const cmd = typeof obj.command === 'string' ? obj.command : ''
43
+ const desc = typeof obj.description === 'string' ? obj.description : undefined
44
+ return { primary: cmd, secondary: desc }
45
+ }
46
+ case 'Edit': {
47
+ const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
48
+ return { primary: filePath, secondary: obj.old_string ? t('permission.replacingContent') : undefined }
49
+ }
50
+ case 'Write': {
51
+ const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
52
+ return { primary: filePath }
53
+ }
54
+ case 'Read': {
55
+ const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
56
+ return { primary: filePath }
57
+ }
58
+ case 'Glob':
59
+ return { primary: typeof obj.pattern === 'string' ? obj.pattern : '' }
60
+ case 'Grep':
61
+ return { primary: typeof obj.pattern === 'string' ? obj.pattern : '' }
62
+ case 'Agent':
63
+ return { primary: typeof obj.description === 'string' ? obj.description : '' }
64
+ case 'WebSearch':
65
+ return { primary: typeof obj.query === 'string' ? obj.query : '' }
66
+ case 'WebFetch':
67
+ return { primary: typeof obj.url === 'string' ? obj.url : '' }
68
+ default:
69
+ return { primary: typeof input === 'string' ? input : JSON.stringify(input, null, 2) }
70
+ }
71
+ }
72
+
73
+ function getPermissionTitle(toolName: string, input: unknown, t: (key: TranslationKey, params?: Record<string, string | number>) => string) {
74
+ const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
75
+ const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
76
+ const fileName = filePath ? filePath.split('/').pop() || filePath : ''
77
+
78
+ switch (toolName) {
79
+ case 'Edit':
80
+ case 'Write':
81
+ return fileName ? t('permission.allowEditFile', { toolName, fileName }) : t('permission.allowEditFileGeneric', { toolName: toolName.toLowerCase() })
82
+ case 'Bash':
83
+ return t('permission.allowBash')
84
+ default:
85
+ return t('permission.allowTool', { toolName })
86
+ }
87
+ }
88
+
89
+ function renderPermissionPreview(toolName: string, input: unknown) {
90
+ const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
91
+ const filePath = typeof obj.file_path === 'string' ? obj.file_path : 'file'
92
+
93
+ if (toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string') {
94
+ return <DiffViewer filePath={filePath} oldString={obj.old_string} newString={obj.new_string} />
95
+ }
96
+
97
+ if (toolName === 'Write' && typeof obj.content === 'string') {
98
+ return <DiffViewer filePath={filePath} oldString="" newString={obj.content} />
99
+ }
100
+
101
+ if (toolName === 'Bash' && typeof obj.command === 'string') {
102
+ return (
103
+ <div className="overflow-x-auto rounded-[var(--radius-md)] bg-[var(--color-terminal-bg)] px-3 py-2.5">
104
+ <pre className="font-[var(--font-mono)] text-[11px] leading-[1.3] text-[var(--color-terminal-fg)] whitespace-pre-wrap break-words">
105
+ <span className="text-[var(--color-terminal-accent)] select-none">$ </span>{obj.command}
106
+ </pre>
107
+ </div>
108
+ )
109
+ }
110
+
111
+ return null
112
+ }
113
+
114
+ export function PermissionDialog({ requestId, toolName, input, description }: Props) {
115
+ const { respondToPermission } = useChatStore()
116
+ const activeTabId = useTabStore((s) => s.activeTabId)
117
+ const pendingPermission = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.pendingPermission : undefined)
118
+ const t = useTranslation()
119
+ const isPending = pendingPermission?.requestId === requestId
120
+ const [showRaw, setShowRaw] = useState(false)
121
+
122
+ const meta = TOOL_META[toolName] || { icon: 'shield', label: toolName, color: 'var(--color-text-tertiary)' }
123
+ const details = extractToolDetails(toolName, input, t)
124
+ const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
125
+ const preview = renderPermissionPreview(toolName, input)
126
+ const title = getPermissionTitle(toolName, input, t)
127
+ const allowRawToggle = !preview
128
+
129
+ return (
130
+ <div className={`mb-4 ml-10 overflow-hidden rounded-[var(--radius-lg)] border ${
131
+ isPending
132
+ ? 'border-[var(--color-warning)] bg-[var(--color-surface-container-lowest)]'
133
+ : 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'
134
+ }`}>
135
+ {/* Header */}
136
+ <div className={`flex items-center gap-3 px-4 py-3 ${
137
+ isPending
138
+ ? 'bg-[var(--color-surface-container)]'
139
+ : 'bg-[var(--color-surface-container-low)]'
140
+ }`}>
141
+ <div
142
+ className="flex items-center justify-center w-8 h-8 rounded-[var(--radius-md)]"
143
+ style={{ backgroundColor: `${meta.color}18` }}
144
+ >
145
+ <span
146
+ className="material-symbols-outlined text-[18px]"
147
+ style={{ color: meta.color }}
148
+ >
149
+ {meta.icon}
150
+ </span>
151
+ </div>
152
+ <div className="flex-1 min-w-0">
153
+ <div className="flex items-center gap-2">
154
+ <span className="text-sm font-semibold text-[var(--color-text-primary)]">
155
+ {title}
156
+ </span>
157
+ {isPending && (
158
+ <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-warning)]/15 text-[var(--color-warning)]">
159
+ <span className="w-1.5 h-1.5 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
160
+ {t('permission.awaitingApproval')}
161
+ </span>
162
+ )}
163
+ {!isPending && (
164
+ <span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]">
165
+ {t('permission.responded')}
166
+ </span>
167
+ )}
168
+ </div>
169
+ {description && (
170
+ <p className="mt-0.5 text-xs text-[var(--color-text-secondary)] truncate">{description}</p>
171
+ )}
172
+ </div>
173
+ </div>
174
+
175
+ {/* Tool details */}
176
+ <div className="border-t border-[var(--color-outline-variant)]/20 px-4 py-3">
177
+ {preview ? (
178
+ <div className="space-y-2">
179
+ {details.primary && toolName !== 'Bash' ? (
180
+ <div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs font-[var(--font-mono)] text-[var(--color-text-secondary)]">
181
+ <span className="material-symbols-outlined text-[14px] text-[var(--color-outline)] flex-shrink-0">
182
+ folder_open
183
+ </span>
184
+ <span className="truncate">{details.primary}</span>
185
+ </div>
186
+ ) : null}
187
+ {preview}
188
+ </div>
189
+ ) : details.primary ? (
190
+ <div className="mb-2">
191
+ <div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs font-[var(--font-mono)] text-[var(--color-text-secondary)]">
192
+ <span className="material-symbols-outlined text-[14px] text-[var(--color-outline)] flex-shrink-0">
193
+ {toolName === 'Glob' || toolName === 'Grep' ? 'search' : 'folder_open'}
194
+ </span>
195
+ <span className="truncate">{details.primary}</span>
196
+ </div>
197
+ </div>
198
+ ) : null}
199
+
200
+ {/* Secondary detail */}
201
+ {details.secondary && (
202
+ <p className="mt-2 text-xs text-[var(--color-text-tertiary)]">{details.secondary}</p>
203
+ )}
204
+
205
+ {allowRawToggle && (
206
+ <button
207
+ onClick={() => setShowRaw(!showRaw)}
208
+ className="mt-2 flex cursor-pointer items-center gap-1 text-[11px] text-[var(--color-text-accent)] hover:underline"
209
+ >
210
+ <span className="material-symbols-outlined text-[14px]">
211
+ {showRaw ? 'expand_less' : 'expand_more'}
212
+ </span>
213
+ {showRaw ? t('permission.hideDetails') : t('permission.showFullInput')}
214
+ </button>
215
+ )}
216
+
217
+ {allowRawToggle && showRaw && (
218
+ <pre className="mt-2 max-h-[220px] overflow-y-auto overflow-x-auto rounded-[var(--radius-md)] bg-[var(--color-terminal-bg)] px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.3] text-[var(--color-terminal-fg)] whitespace-pre-wrap break-words">
219
+ {rawInput}
220
+ </pre>
221
+ )}
222
+ </div>
223
+
224
+ {/* Action buttons */}
225
+ {isPending && (
226
+ <div className="flex items-center gap-2 border-t border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] px-4 py-3">
227
+ <Button
228
+ variant="primary"
229
+ size="sm"
230
+ onClick={() => activeTabId && respondToPermission(activeTabId, requestId, true)}
231
+ icon={
232
+ <span className="material-symbols-outlined text-[14px]">check</span>
233
+ }
234
+ >
235
+ {t('permission.allow')}
236
+ </Button>
237
+ <Button
238
+ variant="ghost"
239
+ size="sm"
240
+ onClick={() => activeTabId && respondToPermission(activeTabId, requestId, true, 'always')}
241
+ icon={
242
+ <span className="material-symbols-outlined text-[14px]">verified</span>
243
+ }
244
+ >
245
+ {t('permission.allowForSession')}
246
+ </Button>
247
+ <div className="flex-1" />
248
+ <Button
249
+ variant="danger"
250
+ size="sm"
251
+ onClick={() => activeTabId && respondToPermission(activeTabId, requestId, false)}
252
+ icon={
253
+ <span className="material-symbols-outlined text-[14px]">close</span>
254
+ }
255
+ >
256
+ {t('permission.deny')}
257
+ </Button>
258
+ </div>
259
+ )}
260
+ </div>
261
+ )
262
+ }
@@ -0,0 +1,99 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { act, fireEvent, render, screen } from '@testing-library/react'
3
+ import '@testing-library/jest-dom'
4
+ import { SessionTaskBar } from './SessionTaskBar'
5
+ import { useCLITaskStore } from '../../stores/cliTaskStore'
6
+
7
+ vi.mock('../../i18n', () => ({
8
+ useTranslation: () => (key: string) => {
9
+ const translations: Record<string, string> = {
10
+ 'tasks.title': 'Tasks',
11
+ 'tasks.dismissCompleted': 'Hide completed tasks',
12
+ }
13
+
14
+ return translations[key] ?? key
15
+ },
16
+ }))
17
+
18
+ describe('SessionTaskBar', () => {
19
+ beforeEach(() => {
20
+ useCLITaskStore.setState({
21
+ sessionId: 'session-1',
22
+ tasks: [],
23
+ expanded: false,
24
+ completedAndDismissed: false,
25
+ dismissedCompletionKey: null,
26
+ })
27
+ })
28
+
29
+ afterEach(() => {
30
+ useCLITaskStore.getState().clearTasks()
31
+ })
32
+
33
+ it('only shows the dismiss button once every task is completed', () => {
34
+ act(() => {
35
+ useCLITaskStore.getState().setTasksFromTodos([
36
+ { content: 'first', status: 'completed' },
37
+ { content: 'second', status: 'in_progress', activeForm: 'working' },
38
+ ])
39
+ })
40
+
41
+ act(() => {
42
+ render(<SessionTaskBar />)
43
+ })
44
+
45
+ expect(screen.getByText('Tasks')).toBeInTheDocument()
46
+ expect(screen.queryByRole('button', { name: 'Hide completed tasks' })).toBeNull()
47
+ })
48
+
49
+ it('hides the bar after dismissing a completed task set', () => {
50
+ act(() => {
51
+ useCLITaskStore.getState().setTasksFromTodos([
52
+ { content: 'first', status: 'completed' },
53
+ { content: 'second', status: 'completed' },
54
+ ])
55
+ })
56
+
57
+ act(() => {
58
+ render(<SessionTaskBar />)
59
+ })
60
+
61
+ fireEvent.click(screen.getByRole('button', { name: 'Hide completed tasks' }))
62
+
63
+ expect(screen.queryByText('Tasks')).toBeNull()
64
+ expect(useCLITaskStore.getState().completedAndDismissed).toBe(true)
65
+ })
66
+
67
+ it('shows the bar again for a new task cycle after a previous completed set was dismissed', () => {
68
+ act(() => {
69
+ useCLITaskStore.getState().setTasksFromTodos([
70
+ { content: 'first', status: 'completed' },
71
+ ])
72
+ })
73
+
74
+ act(() => {
75
+ render(<SessionTaskBar />)
76
+ })
77
+
78
+ fireEvent.click(screen.getByRole('button', { name: 'Hide completed tasks' }))
79
+ expect(screen.queryByText('Tasks')).toBeNull()
80
+
81
+ act(() => {
82
+ useCLITaskStore.getState().setTasksFromTodos([
83
+ { content: 'next task', status: 'in_progress', activeForm: 'running next task' },
84
+ ])
85
+ })
86
+
87
+ expect(screen.getByText('Tasks')).toBeInTheDocument()
88
+ expect(screen.queryByRole('button', { name: 'Hide completed tasks' })).toBeNull()
89
+
90
+ act(() => {
91
+ useCLITaskStore.getState().setTasksFromTodos([
92
+ { content: 'next task', status: 'completed' },
93
+ ])
94
+ })
95
+
96
+ expect(screen.getByText('Tasks')).toBeInTheDocument()
97
+ expect(screen.getByRole('button', { name: 'Hide completed tasks' })).toBeInTheDocument()
98
+ })
99
+ })
@@ -0,0 +1,159 @@
1
+ import { useCLITaskStore } from '../../stores/cliTaskStore'
2
+ import { useTranslation } from '../../i18n'
3
+ import type { CLITask } from '../../types/cliTask'
4
+
5
+ const statusConfig = {
6
+ pending: {
7
+ icon: 'radio_button_unchecked',
8
+ color: 'var(--color-text-tertiary)',
9
+ label: 'pending',
10
+ },
11
+ in_progress: {
12
+ icon: 'pending',
13
+ color: 'var(--color-warning)',
14
+ label: 'active',
15
+ },
16
+ completed: {
17
+ icon: 'check_circle',
18
+ color: 'var(--color-success)',
19
+ label: 'done',
20
+ },
21
+ } as const
22
+
23
+ export function SessionTaskBar() {
24
+ const {
25
+ tasks,
26
+ expanded,
27
+ toggleExpanded,
28
+ completedAndDismissed,
29
+ markCompletedAndDismissed,
30
+ } = useCLITaskStore()
31
+ const t = useTranslation()
32
+
33
+ if (tasks.length === 0) return null
34
+
35
+ // Don't show sticky bar if tasks were completed and the user already continued chatting
36
+ const allCompleted = tasks.every((tk) => tk.status === 'completed')
37
+ if (allCompleted && completedAndDismissed) return null
38
+
39
+ const completedCount = tasks.filter((tk) => tk.status === 'completed').length
40
+ const totalCount = tasks.length
41
+ const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
42
+
43
+ return (
44
+ <div className="shrink-0 px-8">
45
+ <div className="mx-auto max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] overflow-hidden mb-2">
46
+ {/* Header — always visible, clickable to toggle */}
47
+ <div className="flex items-center gap-2 bg-[var(--color-surface-container)] px-2 py-1.5">
48
+ <button
49
+ type="button"
50
+ onClick={toggleExpanded}
51
+ className="flex min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-2 py-1 hover:bg-[var(--color-surface-container-low)] transition-colors"
52
+ >
53
+ <div className="flex items-center justify-center w-6 h-6 rounded-[var(--radius-md)] bg-[var(--color-secondary)]/10">
54
+ <span
55
+ className="material-symbols-outlined text-[14px] text-[var(--color-secondary)]"
56
+ >
57
+ checklist
58
+ </span>
59
+ </div>
60
+
61
+ <span className="text-xs font-semibold text-[var(--color-text-primary)]">
62
+ {t('tasks.title')}
63
+ </span>
64
+
65
+ {/* Progress bar */}
66
+ <div className="flex-1 h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden max-w-[200px]">
67
+ <div
68
+ className="h-full rounded-full transition-all duration-300"
69
+ style={{
70
+ width: `${progressPercent}%`,
71
+ backgroundColor: completedCount === totalCount
72
+ ? 'var(--color-success)'
73
+ : 'var(--color-brand)',
74
+ }}
75
+ />
76
+ </div>
77
+
78
+ <span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">
79
+ {completedCount}/{totalCount}
80
+ </span>
81
+
82
+ <span
83
+ className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)] transition-transform duration-200"
84
+ style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
85
+ >
86
+ expand_less
87
+ </span>
88
+ </button>
89
+
90
+ {allCompleted && (
91
+ <button
92
+ type="button"
93
+ aria-label={t('tasks.dismissCompleted')}
94
+ onClick={markCompletedAndDismissed}
95
+ className="flex shrink-0 items-center justify-center rounded-[var(--radius-md)] p-1.5 text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-container-low)] hover:text-[var(--color-text-primary)] transition-colors"
96
+ >
97
+ <span className="material-symbols-outlined text-[16px]">close</span>
98
+ </button>
99
+ )}
100
+ </div>
101
+
102
+ {/* Expanded task list */}
103
+ {expanded && (
104
+ <div className="px-4 pb-2 pt-1 flex flex-col gap-0.5 max-h-[240px] overflow-y-auto border-t border-[var(--color-outline-variant)]/20">
105
+ {tasks.map((task) => (
106
+ <TaskItem key={task.id} task={task} />
107
+ ))}
108
+ </div>
109
+ )}
110
+ </div>
111
+ </div>
112
+ )
113
+ }
114
+
115
+ function TaskItem({ task }: { task: CLITask }) {
116
+ const config = statusConfig[task.status]
117
+
118
+ return (
119
+ <div className="flex items-start gap-2 py-1.5 px-1 rounded-md">
120
+ <span
121
+ className="material-symbols-outlined text-[16px] mt-px shrink-0"
122
+ style={{ color: config.color, fontVariationSettings: "'FILL' 1" }}
123
+ >
124
+ {config.icon}
125
+ </span>
126
+
127
+ <div className="flex-1 min-w-0">
128
+ <div className="flex items-center gap-1.5">
129
+ <span className="text-[10px] font-mono text-[var(--color-text-tertiary)]">
130
+ #{task.id}
131
+ </span>
132
+ <span className={`text-xs ${
133
+ task.status === 'completed'
134
+ ? 'text-[var(--color-text-tertiary)] line-through'
135
+ : 'text-[var(--color-text-primary)]'
136
+ }`}>
137
+ {task.subject}
138
+ </span>
139
+ </div>
140
+
141
+ {task.status === 'in_progress' && task.activeForm && (
142
+ <div className="flex items-center gap-1 mt-0.5">
143
+ <span className="w-1.5 h-1.5 rounded-full bg-[var(--color-warning)] animate-pulse" />
144
+ <span className="text-[10px] text-[var(--color-warning)]">
145
+ {task.activeForm}
146
+ </span>
147
+ </div>
148
+ )}
149
+
150
+ {task.owner && (
151
+ <span className="text-[10px] text-[var(--color-text-tertiary)] mt-0.5 inline-flex items-center gap-0.5">
152
+ <span className="material-symbols-outlined text-[10px]">person</span>
153
+ {task.owner}
154
+ </span>
155
+ )}
156
+ </div>
157
+ </div>
158
+ )
159
+ }
@@ -0,0 +1,41 @@
1
+ import { useChatStore } from '../../stores/chatStore'
2
+ import { useTabStore } from '../../stores/tabStore'
3
+
4
+ function formatElapsed(seconds: number): string {
5
+ if (seconds < 60) return `${seconds}s`
6
+ const m = Math.floor(seconds / 60)
7
+ const s = seconds % 60
8
+ return `${m}m ${s}s`
9
+ }
10
+
11
+ export function StreamingIndicator() {
12
+ const activeTabId = useTabStore((s) => s.activeTabId)
13
+ const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
14
+ const chatState = sessionState?.chatState ?? 'idle'
15
+ const statusVerb = sessionState?.statusVerb ?? ''
16
+ const elapsedSeconds = sessionState?.elapsedSeconds ?? 0
17
+ const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
18
+ let verb: string
19
+ if (statusVerb) {
20
+ verb = statusVerb
21
+ } else {
22
+ verb = chatState === 'thinking' ? 'Thinking' : chatState === 'tool_executing' ? 'Running' : 'Working'
23
+ }
24
+
25
+ return (
26
+ <div className="mb-2 ml-10 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1">
27
+ <span className="text-[var(--color-brand)] animate-shimmer text-xs">✦</span>
28
+ <span className="text-xs font-medium text-[var(--color-text-secondary)]">{verb}...</span>
29
+ {elapsedSeconds > 0 && (
30
+ <span className="text-[10px] text-[var(--color-text-tertiary)]">
31
+ {formatElapsed(elapsedSeconds)}
32
+ </span>
33
+ )}
34
+ {tokenUsage.output_tokens > 0 && (
35
+ <span className="text-[10px] text-[var(--color-text-tertiary)]">
36
+ · ↓ {tokenUsage.output_tokens}
37
+ </span>
38
+ )}
39
+ </div>
40
+ )
41
+ }
@@ -0,0 +1,35 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ type Props = {
4
+ title?: string
5
+ children: ReactNode
6
+ className?: string
7
+ }
8
+
9
+ /**
10
+ * macOS-style terminal window decoration with traffic light buttons.
11
+ * Reusable wrapper for Bash commands, tool results, and code viewers.
12
+ */
13
+ export function TerminalChrome({ title, children, className = '' }: Props) {
14
+ return (
15
+ <div className={`overflow-hidden rounded-2xl border border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-dim)] ${className}`}>
16
+ {/* Title bar with traffic lights */}
17
+ <div className="flex items-center gap-2 border-b border-[var(--color-terminal-border)] bg-[var(--color-terminal-header)] px-3 py-2">
18
+ <div className="flex gap-1.5">
19
+ <div className="w-2.5 h-2.5 rounded-full bg-[var(--color-terminal-danger)]" />
20
+ <div className="w-2.5 h-2.5 rounded-full bg-[var(--color-terminal-warning)]" />
21
+ <div className="w-2.5 h-2.5 rounded-full bg-[var(--color-terminal-accent)]" />
22
+ </div>
23
+ {title && (
24
+ <span className="ml-2 truncate font-[var(--font-mono)] text-[10px] text-[var(--color-terminal-muted)]">
25
+ {title}
26
+ </span>
27
+ )}
28
+ </div>
29
+ {/* Content */}
30
+ <div className="bg-[var(--color-terminal-bg)] text-[var(--color-terminal-fg)]">
31
+ {children}
32
+ </div>
33
+ </div>
34
+ )
35
+ }
@@ -0,0 +1,87 @@
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import { useTranslation } from '../../i18n'
3
+
4
+ export function ThinkingBlock({ content, isActive = false }: { content: string; isActive?: boolean }) {
5
+ const t = useTranslation()
6
+ const [expanded, setExpanded] = useState(false)
7
+ const contentRef = useRef<HTMLDivElement>(null)
8
+
9
+ useEffect(() => {
10
+ if (expanded && isActive && contentRef.current) {
11
+ contentRef.current.scrollTop = contentRef.current.scrollHeight
12
+ }
13
+ }, [content, expanded, isActive])
14
+
15
+ // Preview: take first meaningful line, not first 140 chars
16
+ const lines = content.split('\n').filter((l) => l.trim())
17
+ const firstLine = lines[0]?.replace(/\s+/g, ' ').trim() || ''
18
+ const preview = firstLine.length > 80 ? firstLine.slice(0, 80) + '...' : firstLine
19
+
20
+ return (
21
+ <div className="mb-1 ml-10">
22
+ <style>{thinkingStyles}</style>
23
+ <button
24
+ onClick={() => setExpanded((v) => !v)}
25
+ className="flex w-full items-center gap-1.5 rounded-md px-1 py-0.5 text-left text-[12px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-secondary)]"
26
+ >
27
+ <span className="text-[10px] text-[var(--color-outline)]">
28
+ {expanded ? '\u25BE' : '\u25B8'}
29
+ </span>
30
+ <span className="shrink-0 font-medium italic">
31
+ {t('thinking.label')}
32
+ {isActive && <span className="thinking-dots" />}
33
+ </span>
34
+ {!expanded && preview && (
35
+ <span className="min-w-0 flex-1 truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
36
+ {preview}
37
+ {isActive && <span className="thinking-inline-cursor" />}
38
+ </span>
39
+ )}
40
+ </button>
41
+ {expanded && (
42
+ <div
43
+ ref={contentRef}
44
+ className="mt-1 max-h-[300px] overflow-y-auto rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-lowest)] p-2.5 font-[var(--font-mono)] text-[11px] leading-[1.35] text-[var(--color-text-secondary)] whitespace-pre-wrap break-words"
45
+ >
46
+ {content}
47
+ {isActive && expanded && <span className="thinking-cursor" />}
48
+ </div>
49
+ )}
50
+ </div>
51
+ )
52
+ }
53
+
54
+ const thinkingStyles = `
55
+ @keyframes thinking-cursor-blink {
56
+ 0%, 100% { opacity: 1; }
57
+ 50% { opacity: 0; }
58
+ }
59
+ @keyframes thinking-dots {
60
+ 0%, 20% { content: ''; }
61
+ 40% { content: '.'; }
62
+ 60% { content: '..'; }
63
+ 80%, 100% { content: '...'; }
64
+ }
65
+ .thinking-cursor {
66
+ display: inline-block;
67
+ width: 2px;
68
+ height: 1em;
69
+ background: var(--color-text-tertiary);
70
+ vertical-align: middle;
71
+ margin-left: 1px;
72
+ animation: thinking-cursor-blink 1s step-end infinite;
73
+ }
74
+ .thinking-inline-cursor {
75
+ display: inline-block;
76
+ width: 1px;
77
+ height: 0.95em;
78
+ margin-left: 3px;
79
+ vertical-align: text-bottom;
80
+ background: var(--color-text-tertiary);
81
+ animation: thinking-cursor-blink 1s step-end infinite;
82
+ }
83
+ .thinking-dots::after {
84
+ content: '';
85
+ animation: thinking-dots 1.4s steps(1, end) infinite;
86
+ }
87
+ `