illusion-code 0.1.0__py3-none-any.whl

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 (214) hide show
  1. illusion/__init__.py +24 -0
  2. illusion/__main__.py +15 -0
  3. illusion/_frontend/dist/index.mjs +39208 -0
  4. illusion/_frontend/package.json +27 -0
  5. illusion/_frontend/src/App.tsx +624 -0
  6. illusion/_frontend/src/components/CommandPicker.tsx +98 -0
  7. illusion/_frontend/src/components/Composer.tsx +55 -0
  8. illusion/_frontend/src/components/ComposerController.tsx +128 -0
  9. illusion/_frontend/src/components/ConversationView.tsx +750 -0
  10. illusion/_frontend/src/components/Footer.tsx +25 -0
  11. illusion/_frontend/src/components/MarkdownContent.tsx +537 -0
  12. illusion/_frontend/src/components/MarkdownTable.tsx +245 -0
  13. illusion/_frontend/src/components/ModalHost.tsx +425 -0
  14. illusion/_frontend/src/components/MultilineTextInput.tsx +250 -0
  15. illusion/_frontend/src/components/PromptInput.tsx +64 -0
  16. illusion/_frontend/src/components/SelectModal.tsx +78 -0
  17. illusion/_frontend/src/components/SidePanel.tsx +175 -0
  18. illusion/_frontend/src/components/Spinner.tsx +77 -0
  19. illusion/_frontend/src/components/StatusBar.tsx +142 -0
  20. illusion/_frontend/src/components/SwarmPanel.tsx +141 -0
  21. illusion/_frontend/src/components/TodoPanel.tsx +126 -0
  22. illusion/_frontend/src/components/ToolCallDisplay.tsx +202 -0
  23. illusion/_frontend/src/components/TranscriptPane.tsx +79 -0
  24. illusion/_frontend/src/components/WelcomeBanner.tsx +37 -0
  25. illusion/_frontend/src/hooks/useBackendSession.ts +468 -0
  26. illusion/_frontend/src/hooks/useTerminalSize.ts +9 -0
  27. illusion/_frontend/src/i18n.ts +78 -0
  28. illusion/_frontend/src/index.tsx +42 -0
  29. illusion/_frontend/src/theme/ThemeContext.tsx +19 -0
  30. illusion/_frontend/src/theme/builtinThemes.ts +89 -0
  31. illusion/_frontend/src/types.ts +110 -0
  32. illusion/_frontend/src/utils/markdown.ts +33 -0
  33. illusion/_frontend/src/utils/thinking.ts +191 -0
  34. illusion/_frontend/tsconfig.json +13 -0
  35. illusion/_web_dist/assets/index-BseIw-ik.css +10 -0
  36. illusion/_web_dist/assets/index-C_0ZWMuW.js +82 -0
  37. illusion/_web_dist/index.html +16 -0
  38. illusion/api/__init__.py +36 -0
  39. illusion/api/client.py +568 -0
  40. illusion/api/codex_client.py +563 -0
  41. illusion/api/compat.py +138 -0
  42. illusion/api/effort.py +128 -0
  43. illusion/api/errors.py +57 -0
  44. illusion/api/openai_client.py +819 -0
  45. illusion/api/provider.py +148 -0
  46. illusion/api/registry.py +479 -0
  47. illusion/api/usage.py +45 -0
  48. illusion/auth/__init__.py +50 -0
  49. illusion/auth/copilot.py +419 -0
  50. illusion/auth/external.py +612 -0
  51. illusion/auth/flows.py +58 -0
  52. illusion/auth/manager.py +214 -0
  53. illusion/auth/storage.py +372 -0
  54. illusion/bridge/__init__.py +38 -0
  55. illusion/bridge/manager.py +190 -0
  56. illusion/bridge/session_runner.py +84 -0
  57. illusion/bridge/types.py +113 -0
  58. illusion/bridge/work_secret.py +131 -0
  59. illusion/cli.py +1228 -0
  60. illusion/commands/__init__.py +32 -0
  61. illusion/commands/registry.py +1934 -0
  62. illusion/config/__init__.py +39 -0
  63. illusion/config/i18n.py +522 -0
  64. illusion/config/paths.py +259 -0
  65. illusion/config/settings.py +564 -0
  66. illusion/coordinator/__init__.py +41 -0
  67. illusion/coordinator/agent_definitions.py +1093 -0
  68. illusion/coordinator/coordinator_mode.py +127 -0
  69. illusion/engine/__init__.py +95 -0
  70. illusion/engine/cost_tracker.py +55 -0
  71. illusion/engine/messages.py +369 -0
  72. illusion/engine/query.py +632 -0
  73. illusion/engine/query_engine.py +343 -0
  74. illusion/engine/stream_events.py +169 -0
  75. illusion/hooks/__init__.py +67 -0
  76. illusion/hooks/events.py +43 -0
  77. illusion/hooks/executor.py +397 -0
  78. illusion/hooks/hot_reload.py +74 -0
  79. illusion/hooks/loader.py +133 -0
  80. illusion/hooks/schemas.py +121 -0
  81. illusion/hooks/types.py +86 -0
  82. illusion/mcp/__init__.py +104 -0
  83. illusion/mcp/client.py +377 -0
  84. illusion/mcp/config.py +140 -0
  85. illusion/mcp/types.py +175 -0
  86. illusion/memory/__init__.py +36 -0
  87. illusion/memory/manager.py +94 -0
  88. illusion/memory/memdir.py +58 -0
  89. illusion/memory/paths.py +57 -0
  90. illusion/memory/scan.py +120 -0
  91. illusion/memory/search.py +83 -0
  92. illusion/memory/types.py +43 -0
  93. illusion/output_styles/__init__.py +15 -0
  94. illusion/output_styles/loader.py +64 -0
  95. illusion/permissions/__init__.py +39 -0
  96. illusion/permissions/checker.py +174 -0
  97. illusion/permissions/modes.py +38 -0
  98. illusion/platforms.py +148 -0
  99. illusion/plugins/__init__.py +71 -0
  100. illusion/plugins/bundled/__init__.py +0 -0
  101. illusion/plugins/installer.py +59 -0
  102. illusion/plugins/loader.py +301 -0
  103. illusion/plugins/schemas.py +51 -0
  104. illusion/plugins/types.py +56 -0
  105. illusion/prompts/__init__.py +29 -0
  106. illusion/prompts/claudemd.py +74 -0
  107. illusion/prompts/context.py +187 -0
  108. illusion/prompts/environment.py +189 -0
  109. illusion/prompts/system_prompt.py +155 -0
  110. illusion/py.typed +0 -0
  111. illusion/sandbox/__init__.py +29 -0
  112. illusion/sandbox/adapter.py +174 -0
  113. illusion/services/__init__.py +59 -0
  114. illusion/services/compact/__init__.py +1015 -0
  115. illusion/services/cron.py +338 -0
  116. illusion/services/cron_scheduler.py +715 -0
  117. illusion/services/file_history.py +258 -0
  118. illusion/services/lsp/__init__.py +455 -0
  119. illusion/services/session_storage.py +237 -0
  120. illusion/services/token_estimation.py +72 -0
  121. illusion/skills/__init__.py +60 -0
  122. illusion/skills/bundled/__init__.py +110 -0
  123. illusion/skills/bundled/content/batch.md +86 -0
  124. illusion/skills/bundled/content/coding-guidelines.md +70 -0
  125. illusion/skills/bundled/content/debug.md +38 -0
  126. illusion/skills/bundled/content/loop.md +82 -0
  127. illusion/skills/bundled/content/remember.md +105 -0
  128. illusion/skills/bundled/content/simplify.md +53 -0
  129. illusion/skills/bundled/content/skillify.md +113 -0
  130. illusion/skills/bundled/content/stuck.md +54 -0
  131. illusion/skills/bundled/content/update-config.md +329 -0
  132. illusion/skills/bundled/content/verify.md +74 -0
  133. illusion/skills/loader.py +219 -0
  134. illusion/skills/registry.py +40 -0
  135. illusion/skills/types.py +24 -0
  136. illusion/state/__init__.py +18 -0
  137. illusion/state/app_state.py +67 -0
  138. illusion/state/store.py +93 -0
  139. illusion/swarm/__init__.py +71 -0
  140. illusion/swarm/agent_executor.py +857 -0
  141. illusion/swarm/in_process.py +259 -0
  142. illusion/swarm/subprocess_backend.py +136 -0
  143. illusion/swarm/team_helpers.py +123 -0
  144. illusion/swarm/types.py +159 -0
  145. illusion/swarm/worktree.py +347 -0
  146. illusion/tasks/__init__.py +33 -0
  147. illusion/tasks/local_agent_task.py +42 -0
  148. illusion/tasks/local_shell_task.py +27 -0
  149. illusion/tasks/manager.py +377 -0
  150. illusion/tasks/stop_task.py +21 -0
  151. illusion/tasks/types.py +88 -0
  152. illusion/tools/__init__.py +126 -0
  153. illusion/tools/agent_tool.py +388 -0
  154. illusion/tools/ask_user_question_tool.py +186 -0
  155. illusion/tools/base.py +149 -0
  156. illusion/tools/bash_tool.py +413 -0
  157. illusion/tools/config_tool.py +90 -0
  158. illusion/tools/cron_tool.py +473 -0
  159. illusion/tools/enter_plan_mode_tool.py +147 -0
  160. illusion/tools/enter_worktree_tool.py +188 -0
  161. illusion/tools/exit_plan_mode_tool.py +69 -0
  162. illusion/tools/exit_worktree_tool.py +225 -0
  163. illusion/tools/file_edit_tool.py +283 -0
  164. illusion/tools/file_read_tool.py +294 -0
  165. illusion/tools/file_write_tool.py +184 -0
  166. illusion/tools/glob_tool.py +165 -0
  167. illusion/tools/grep_tool.py +190 -0
  168. illusion/tools/list_mcp_resources_tool.py +80 -0
  169. illusion/tools/lsp_tool.py +333 -0
  170. illusion/tools/mcp_auth_tool.py +100 -0
  171. illusion/tools/mcp_tool.py +75 -0
  172. illusion/tools/notebook_edit_tool.py +242 -0
  173. illusion/tools/powershell_tool.py +334 -0
  174. illusion/tools/read_mcp_resource_tool.py +63 -0
  175. illusion/tools/repl_tool.py +100 -0
  176. illusion/tools/send_message_tool.py +112 -0
  177. illusion/tools/shell_common.py +187 -0
  178. illusion/tools/skill_tool.py +86 -0
  179. illusion/tools/sleep_tool.py +62 -0
  180. illusion/tools/structured_output_tool.py +58 -0
  181. illusion/tools/task_create_tool.py +98 -0
  182. illusion/tools/task_get_tool.py +94 -0
  183. illusion/tools/task_list_tool.py +94 -0
  184. illusion/tools/task_output_tool.py +55 -0
  185. illusion/tools/task_stop_tool.py +52 -0
  186. illusion/tools/task_update_tool.py +224 -0
  187. illusion/tools/team_create_tool.py +236 -0
  188. illusion/tools/team_delete_tool.py +104 -0
  189. illusion/tools/todo_write_tool.py +198 -0
  190. illusion/tools/tool_search_tool.py +156 -0
  191. illusion/tools/web_fetch_tool.py +264 -0
  192. illusion/tools/web_search_tool.py +186 -0
  193. illusion/ui/__init__.py +23 -0
  194. illusion/ui/app.py +258 -0
  195. illusion/ui/backend_host.py +1180 -0
  196. illusion/ui/input.py +86 -0
  197. illusion/ui/output.py +363 -0
  198. illusion/ui/permission_dialog.py +47 -0
  199. illusion/ui/permission_store.py +99 -0
  200. illusion/ui/protocol.py +384 -0
  201. illusion/ui/react_launcher.py +280 -0
  202. illusion/ui/runtime.py +787 -0
  203. illusion/ui/textual_app.py +603 -0
  204. illusion/ui/web/__init__.py +10 -0
  205. illusion/ui/web/server.py +87 -0
  206. illusion/ui/web/ws_host.py +1197 -0
  207. illusion/utils/__init__.py +0 -0
  208. illusion/utils/ripgrep.py +299 -0
  209. illusion/utils/shell.py +248 -0
  210. illusion_code-0.1.0.dist-info/METADATA +1159 -0
  211. illusion_code-0.1.0.dist-info/RECORD +214 -0
  212. illusion_code-0.1.0.dist-info/WHEEL +4 -0
  213. illusion_code-0.1.0.dist-info/entry_points.txt +2 -0
  214. illusion_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,750 @@
1
+ import React, {useEffect, useMemo, useRef, useState} from 'react';
2
+ import {Box, Static, Text} from 'ink';
3
+
4
+ import {useTerminalSize} from '../hooks/useTerminalSize.js';
5
+ import type {UiLanguage} from '../i18n.js';
6
+ import {t} from '../i18n.js';
7
+ import type {PendingToolCall} from '../types.js';
8
+ import type {ThemeConfig} from '../theme/ThemeContext.js';
9
+ import {useTheme} from '../theme/ThemeContext.js';
10
+ import type {TranscriptItem} from '../types.js';
11
+ import {stringWidth, wrapText} from '../utils/markdown.js';
12
+ import {renderAssistantText, stripThinkTags, extractThinkContent, hasThinkTags, stripToolCallArtifacts, mergeReasoning} from '../utils/thinking.js';
13
+ import {MarkdownContent, renderInlineMarkdown} from './MarkdownContent.js';
14
+ import {WelcomeBanner} from './WelcomeBanner.js';
15
+
16
+ const MAX_RESULT_LINES = 2;
17
+ const MAX_COMMAND_LINES = 2;
18
+ const MAX_COMMAND_CHARS = 160;
19
+ const STREAMING_TAIL_LINES = 10;
20
+ const MIN_WRAP_WIDTH = 12;
21
+ const WIDTH_SAFETY_EXTRA = 2;
22
+
23
+ export function ConversationView({
24
+ staticItems,
25
+ clearCount,
26
+ assistantBuffer,
27
+ showWelcome,
28
+ showThinking,
29
+ language,
30
+ pendingToolCalls,
31
+ }: {
32
+ staticItems: TranscriptItem[];
33
+ clearCount: number;
34
+ assistantBuffer: string;
35
+ showWelcome: boolean;
36
+ showThinking: boolean;
37
+ language: UiLanguage;
38
+ pendingToolCalls?: PendingToolCall[];
39
+ commandPickerOpen?: boolean;
40
+ }): React.JSX.Element {
41
+ const theme = useTheme();
42
+ const {columns: terminalWidth} = useTerminalSize();
43
+ const filtered = useMemo(() => staticItems.filter((item) => {
44
+ if (!isEmptyItem(item)) {
45
+ if (item.role === 'user' && item.text.startsWith('/')) {
46
+ return false;
47
+ }
48
+ return true;
49
+ }
50
+ return false;
51
+ }), [staticItems]);
52
+ const grouped = useMemo(() => groupToolItems(filtered), [filtered]);
53
+ const displayItems = useMemo<DisplayEntry[]>(() => {
54
+ const entries: GroupEntry[] = showWelcome
55
+ ? [{type: 'welcome', role: 'welcome'}, ...grouped]
56
+ : grouped;
57
+ return entries.map((entry, index) => ({
58
+ key: `s-${index}`,
59
+ entry,
60
+ prevRole: index > 0 ? entries[index - 1]?.role : undefined,
61
+ }));
62
+ }, [grouped, showWelcome]);
63
+ const displayedBuffer = assistantBuffer; // Already processed in useBackendSession
64
+ const isSuppressedByStatic = useMemo(() => {
65
+ if (!displayedBuffer) return false;
66
+ const lastAssistant = [...grouped].reverse().find((entry) => entry.role === 'assistant');
67
+ if (!lastAssistant) return false;
68
+ const item = lastAssistant.type === 'single' ? lastAssistant.item : null;
69
+ if (!item) return false;
70
+ const staticDisplayText = renderAssistantText(item.text, showThinking, item.reasoning);
71
+ return isTextSubsetOrEqual(staticDisplayText, displayedBuffer);
72
+ }, [grouped, displayedBuffer, showThinking]);
73
+
74
+ return (
75
+ <>
76
+ <Static key={clearCount} items={displayItems}>
77
+ {(display) => {
78
+ const {entry, prevRole, key} = display;
79
+ if (entry.type === 'welcome') {
80
+ return <WelcomeBanner key={key} language={language} />;
81
+ }
82
+ if (entry.type === 'tool_group') {
83
+ return <ToolGroupRow key={key} toolItem={entry.toolItem} resultItem={entry.resultItem} theme={theme} prevRole={prevRole} terminalWidth={terminalWidth} />;
84
+ }
85
+ return <MessageRow key={key} item={entry.item} theme={theme} language={language} prevRole={prevRole} showThinking={showThinking} terminalWidth={terminalWidth} />;
86
+ }}
87
+ </Static>
88
+
89
+ {displayedBuffer && !isSuppressedByStatic ? renderStreamingTail(displayedBuffer, grouped, theme, terminalWidth) : null}
90
+
91
+ {/* Pending tool call indicators — ● 闪烁表示工具正在执行中 */}
92
+ {pendingToolCalls && pendingToolCalls.length > 0 ? (
93
+ <Box marginTop={displayedBuffer || isSuppressedByStatic ? 0 : 1} flexDirection="column">
94
+ {pendingToolCalls.map((pc) => (
95
+ <BlinkingToolIndicator
96
+ key={pc.tool_use_id}
97
+ pending={pc}
98
+ theme={theme}
99
+ terminalWidth={terminalWidth}
100
+ />
101
+ ))}
102
+ </Box>
103
+ ) : null}
104
+ </>
105
+ );
106
+ }
107
+
108
+ function BlinkingToolIndicator({
109
+ pending,
110
+ theme,
111
+ terminalWidth,
112
+ }: {
113
+ pending: PendingToolCall;
114
+ theme: ThemeConfig;
115
+ terminalWidth: number;
116
+ }): React.JSX.Element {
117
+ const [visible, setVisible] = useState(true);
118
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
119
+
120
+ useEffect(() => {
121
+ intervalRef.current = setInterval(() => {
122
+ setVisible((v) => !v);
123
+ }, 500);
124
+ return () => {
125
+ if (intervalRef.current) {
126
+ clearInterval(intervalRef.current);
127
+ }
128
+ };
129
+ }, []);
130
+
131
+ const summary = pending.tool_input
132
+ ? summarizeInput(pending.tool_name, pending.tool_input, pending.tool_name)
133
+ : null;
134
+ const content = summary ? `${pending.tool_name} (${summary})` : pending.tool_name;
135
+ const prefix = `${theme.icons.tool} `;
136
+ const continuationPrefix = ' '.repeat(stringWidth(prefix));
137
+ const wrapped = wrapForPrefix(content, terminalWidth, prefix);
138
+
139
+ return (
140
+ <Box flexDirection="column">
141
+ {wrapped.map((line, i) => (
142
+ <Box key={i}>
143
+ {i === 0 ? (
144
+ <Text>
145
+ <Text color={theme.colors.info}>
146
+ {visible ? theme.icons.tool : ' '}
147
+ {' '}
148
+ </Text>
149
+ <Text bold>{line}</Text>
150
+ </Text>
151
+ ) : (
152
+ <Text bold>{continuationPrefix}{line}</Text>
153
+ )}
154
+ </Box>
155
+ ))}
156
+ </Box>
157
+ );
158
+ }
159
+
160
+ function isEmptyItem(item: TranscriptItem): boolean {
161
+ if (item.role === 'assistant' && (!item.text || item.text.trim() === '') && (!item.reasoning || item.reasoning.trim() === '')) {
162
+ return true;
163
+ }
164
+ if (item.role === 'assistant_streaming' && (!item.text || item.text.trim() === '')) {
165
+ return true;
166
+ }
167
+ if (item.role === 'tool' && (!item.text || item.text.trim() === '') && !item.tool_name) {
168
+ return true;
169
+ }
170
+ return false;
171
+ }
172
+
173
+ type GroupEntry =
174
+ | {type: 'single'; item: TranscriptItem; role: string}
175
+ | {type: 'tool_group'; toolItem: TranscriptItem; resultItem: TranscriptItem | null; role: string}
176
+ | {type: 'welcome'; role: string};
177
+
178
+ type DisplayEntry = {
179
+ key: string;
180
+ entry: GroupEntry;
181
+ prevRole?: string;
182
+ };
183
+
184
+ function groupToolItems(items: TranscriptItem[]): GroupEntry[] {
185
+ const usedResults = new Set<number>();
186
+ const matchedResult = new Map<number, TranscriptItem>();
187
+ // 第一轮:匹配 tool 与 tool_result
188
+ for (let i = 0; i < items.length; i++) {
189
+ const item = items[i];
190
+ if (item.role !== 'tool') continue;
191
+ if (item.tool_use_id) {
192
+ for (let j = i + 1; j < items.length; j++) {
193
+ if (items[j].role === 'tool_result' && items[j].tool_use_id === item.tool_use_id && !usedResults.has(j)) {
194
+ matchedResult.set(i, items[j]);
195
+ usedResults.add(j);
196
+ break;
197
+ }
198
+ }
199
+ }
200
+ if (!matchedResult.has(i)) {
201
+ for (let j = i + 1; j < items.length; j++) {
202
+ if (items[j].role === 'tool_result' && items[j].tool_name === item.tool_name && !usedResults.has(j)) {
203
+ matchedResult.set(i, items[j]);
204
+ usedResults.add(j);
205
+ break;
206
+ }
207
+ }
208
+ }
209
+ }
210
+ // 第二轮:检测 replay 模式(tool 与 result 不相邻)并重排序
211
+ // replay 时所有 tool 在前、所有 result 在后,需要将 result 移到对应 tool 后面
212
+ // 正常流程中 tool 和 result 已相邻,无需重排
213
+ const hasReplayPattern = items.some((item, i) => {
214
+ if (item.role !== 'tool' || !matchedResult.has(i)) return false;
215
+ // 检查下一个 item 是否是对应的 result
216
+ const next = items[i + 1];
217
+ return !next || next.role !== 'tool_result' || next.tool_use_id !== item.tool_use_id;
218
+ });
219
+ if (hasReplayPattern) {
220
+ // 重排序:每个 tool 后面紧跟其 result
221
+ const reordered: TranscriptItem[] = [];
222
+ const unmatchedResults: TranscriptItem[] = [];
223
+ for (let i = 0; i < items.length; i++) {
224
+ const item = items[i];
225
+ if (item.role === 'tool') {
226
+ reordered.push(item);
227
+ const res = matchedResult.get(i);
228
+ if (res) reordered.push(res);
229
+ } else if (item.role === 'tool_result' && usedResults.has(i)) {
230
+ unmatchedResults.push(item); // 已在对应 tool 后面渲染,跳过原位
231
+ } else {
232
+ reordered.push(item);
233
+ }
234
+ }
235
+ return groupToolItemsOrdered(reordered);
236
+ }
237
+ return groupToolItemsOrdered(items);
238
+ }
239
+
240
+ function groupToolItemsOrdered(items: TranscriptItem[]): GroupEntry[] {
241
+ const result: GroupEntry[] = [];
242
+ const usedResults = new Set<number>();
243
+ const resultToTool = new Map<number, number>();
244
+ let i = 0;
245
+ while (i < items.length) {
246
+ const item = items[i];
247
+ if (item.role === 'tool') {
248
+ let resultItem: TranscriptItem | null = null;
249
+ if (item.tool_use_id) {
250
+ for (let j = i + 1; j < items.length; j++) {
251
+ if (items[j].role === 'tool_result' && items[j].tool_use_id === item.tool_use_id && !usedResults.has(j)) {
252
+ resultItem = items[j];
253
+ usedResults.add(j);
254
+ resultToTool.set(j, i);
255
+ break;
256
+ }
257
+ }
258
+ }
259
+ if (!resultItem) {
260
+ for (let j = i + 1; j < items.length; j++) {
261
+ if (items[j].role === 'tool_result' && items[j].tool_name === item.tool_name && !usedResults.has(j)) {
262
+ resultItem = items[j];
263
+ usedResults.add(j);
264
+ resultToTool.set(j, i);
265
+ break;
266
+ }
267
+ }
268
+ }
269
+ result.push({type: 'tool_group', toolItem: item, resultItem, role: 'tool'});
270
+ i += 1;
271
+ continue;
272
+ }
273
+ if (item.role === 'tool_result' && usedResults.has(i)) {
274
+ const toolIdx = resultToTool.get(i)!;
275
+ let hasConcurrentTool = false;
276
+ for (let k = toolIdx + 1; k < i; k++) {
277
+ if (items[k].role === 'tool') {
278
+ hasConcurrentTool = true;
279
+ break;
280
+ }
281
+ }
282
+ if (!hasConcurrentTool) {
283
+ result.push({type: 'single', item, role: 'tool_result'});
284
+ }
285
+ i += 1;
286
+ continue;
287
+ }
288
+ result.push({type: 'single', item, role: item.role});
289
+ i += 1;
290
+ }
291
+ return result;
292
+ }
293
+
294
+ function ToolGroupRow({
295
+ toolItem,
296
+ resultItem,
297
+ theme,
298
+ prevRole,
299
+ terminalWidth,
300
+ }: {
301
+ toolItem: TranscriptItem;
302
+ resultItem: TranscriptItem | null;
303
+ theme: ThemeConfig;
304
+ prevRole?: string;
305
+ terminalWidth: number;
306
+ }): React.JSX.Element {
307
+ const toolName = toolItem.tool_name ?? 'tool';
308
+ const summary = summarizeInput(toolName, toolItem.tool_input, toolItem.text);
309
+ const needsGap = prevRole !== undefined && prevRole !== 'tool' && prevRole !== 'tool_result';
310
+ const prefix = `${theme.icons.tool} `;
311
+ const continuationPrefix = ' '.repeat(stringWidth(prefix));
312
+ const content = summary ? `${toolName} (${summary})` : toolName;
313
+ const wrapped = wrapForPrefix(content, terminalWidth, prefix);
314
+ const continuationDim = false;
315
+
316
+ return (
317
+ <Box flexDirection="column" marginTop={needsGap ? 1 : 0}>
318
+ {wrapped.map((line, i) => (
319
+ <Box key={i}>
320
+ {i === 0 ? (
321
+ <Text>
322
+ <Text color={theme.colors.info}>{prefix}</Text>
323
+ <Text bold>{line}</Text>
324
+ </Text>
325
+ ) : (
326
+ <Text dimColor={continuationDim}>{continuationPrefix}{line}</Text>
327
+ )}
328
+ </Box>
329
+ ))}
330
+ </Box>
331
+ );
332
+ }
333
+
334
+ function ToolResultBlock({
335
+ item,
336
+ theme,
337
+ terminalWidth,
338
+ }: {
339
+ item: TranscriptItem;
340
+ theme: ThemeConfig;
341
+ terminalWidth: number;
342
+ }): React.JSX.Element {
343
+ const lines = item.text.split('\n').filter((l) => l.trim() !== '');
344
+ const truncated = lines.length > MAX_RESULT_LINES;
345
+ const display = truncated
346
+ ? [...lines.slice(0, MAX_RESULT_LINES), `… +${lines.length - MAX_RESULT_LINES} lines`]
347
+ : lines;
348
+
349
+ if (display.length === 0) {
350
+ return (
351
+ <Box>
352
+ <Text dimColor>{` ${theme.icons.resultPrefix} `}</Text>
353
+ <Text color={theme.colors.success}>{theme.icons.check}</Text>
354
+ </Box>
355
+ );
356
+ }
357
+
358
+ const isError = item.is_error;
359
+ const icon = isError ? theme.icons.cross : theme.icons.check;
360
+ const iconColor = isError ? theme.colors.error : theme.colors.success;
361
+ const firstPrefix = ` ${theme.icons.resultPrefix} ${icon} `;
362
+ const firstPrefixText = ` ${theme.icons.resultPrefix} `;
363
+ const continuationPrefix = ' ';
364
+ const firstWidth = Math.max(MIN_WRAP_WIDTH, terminalWidth - stringWidth(firstPrefix) - WIDTH_SAFETY_EXTRA);
365
+ const continuationWidth = Math.max(MIN_WRAP_WIDTH, terminalWidth - stringWidth(continuationPrefix) - WIDTH_SAFETY_EXTRA);
366
+
367
+ return (
368
+ <Box flexDirection="column">
369
+ {display.map((line, i) => {
370
+ // 差异行着色:+行绿色,-行红色,@@行青色
371
+ let lineColor: string | undefined = undefined;
372
+ let lineDim = !isError;
373
+ const trimmedLine = line.trimStart();
374
+ if (trimmedLine.startsWith('+') && !trimmedLine.startsWith('+++')) {
375
+ lineColor = theme.colors.success;
376
+ lineDim = false;
377
+ } else if (trimmedLine.startsWith('-') && !trimmedLine.startsWith('---')) {
378
+ lineColor = theme.colors.error;
379
+ lineDim = false;
380
+ } else if (trimmedLine.startsWith('@@')) {
381
+ lineColor = theme.colors.info;
382
+ lineDim = false;
383
+ }
384
+ // 逐行截断到终端宽度加省略号,避免长行换行破坏预览截断效果
385
+ const width = i === 0 ? firstWidth : continuationWidth;
386
+ const displayLine = truncateToDisplayWidth(line, width);
387
+ const showLeadingIcon = i === 0;
388
+
389
+ return (
390
+ <Box key={i}>
391
+ <Text dimColor>{showLeadingIcon ? firstPrefixText : continuationPrefix}</Text>
392
+ {showLeadingIcon ? (
393
+ <Text color={iconColor}>{icon} </Text>
394
+ ) : null}
395
+ <Text color={isError ? theme.colors.error : lineColor} dimColor={isError ? false : lineDim}>
396
+ {displayLine}
397
+ </Text>
398
+ </Box>
399
+ );
400
+ })}
401
+ </Box>
402
+ );
403
+ }
404
+
405
+ function MessageRow({
406
+ item,
407
+ theme,
408
+ language,
409
+ prevRole,
410
+ showThinking = true,
411
+ terminalWidth,
412
+ }: {
413
+ item: TranscriptItem;
414
+ theme: ThemeConfig;
415
+ language: UiLanguage;
416
+ prevRole?: string;
417
+ showThinking?: boolean;
418
+ terminalWidth: number;
419
+ }): React.JSX.Element {
420
+ switch (item.role) {
421
+ case 'user': {
422
+ const needsDivider = prevRole !== 'user';
423
+ const prefix = `${theme.icons.pointer} `;
424
+ const continuationPrefix = ' '.repeat(stringWidth(prefix));
425
+ const wrapped = wrapForPrefix(item.text, terminalWidth, prefix);
426
+ return (
427
+ <Box flexDirection="column" marginTop={needsDivider ? 1 : 0}>
428
+ {needsDivider ? (
429
+ <Box marginBottom={0}>
430
+ <Text color={theme.colors.text}>{' '}{'─'.repeat(60)}</Text>
431
+ </Box>
432
+ ) : null}
433
+ {wrapped.map((line, i) => (
434
+ <Box key={i}>
435
+ {i === 0 ? (
436
+ <Text>
437
+ <Text color={theme.colors.illusion}>{theme.icons.pointer}</Text>
438
+ <Text bold>{' '}{line}</Text>
439
+ </Text>
440
+ ) : (
441
+ <Text bold>{continuationPrefix}{line}</Text>
442
+ )}
443
+ </Box>
444
+ ))}
445
+ </Box>
446
+ );
447
+ }
448
+
449
+ case 'assistant': {
450
+ const sanitized = stripToolCallArtifacts(item.text);
451
+ const hasTags = hasThinkTags(sanitized);
452
+ let cleanText = sanitized;
453
+ let thinkFromTags = '';
454
+ if (hasTags) {
455
+ thinkFromTags = extractThinkContent(sanitized);
456
+ cleanText = stripThinkTags(sanitized);
457
+ }
458
+ const reasoning = showThinking ? mergeReasoning(item.reasoning, thinkFromTags) : '';
459
+ return (
460
+ <Box flexDirection="column">
461
+ {reasoning ? renderReasoningBlock(reasoning, theme, t(language, 'reasoning'), terminalWidth) : null}
462
+ {renderAssistantBlock(cleanText, theme, terminalWidth, t(language, 'assistantReply'))}
463
+ </Box>
464
+ );
465
+ }
466
+
467
+ case 'assistant_streaming': {
468
+ const isFirst = prevRole !== 'assistant_streaming';
469
+ if (isFirst) {
470
+ return (
471
+ <Box marginTop={1}>
472
+ <Text color={theme.colors.illusion}>{theme.icons.assistant}</Text>
473
+ <Box marginLeft={1} flexGrow={1}>
474
+ <Text>{item.text}</Text>
475
+ </Box>
476
+ </Box>
477
+ );
478
+ }
479
+ return (
480
+ <Box marginLeft={2}>
481
+ <Text>{item.text}</Text>
482
+ </Box>
483
+ );
484
+ }
485
+
486
+ case 'tool_result': {
487
+ return <ToolResultBlock item={item} theme={theme} terminalWidth={terminalWidth} />;
488
+ }
489
+
490
+ case 'system': {
491
+ if (!item.text.trim()) {
492
+ return null;
493
+ }
494
+ const sysLines = item.text.split('\n');
495
+ const firstLine = sysLines[0];
496
+ const restLines = sysLines.slice(1);
497
+ return (
498
+ <Box marginTop={1} flexDirection="column">
499
+ <Text>
500
+ <Text color={theme.colors.warning} italic>{theme.icons.system}</Text>
501
+ <Text color={theme.colors.muted} italic>{' '}{firstLine}</Text>
502
+ </Text>
503
+ {restLines.map((line, idx) => (
504
+ <Box key={idx} marginLeft={2}>
505
+ <Text color={theme.colors.muted} italic>{line}</Text>
506
+ </Box>
507
+ ))}
508
+ </Box>
509
+ );
510
+ }
511
+
512
+ case 'log':
513
+ return (
514
+ <Box>
515
+ <Text dimColor>{item.text}</Text>
516
+ </Box>
517
+ );
518
+
519
+ default:
520
+ return (
521
+ <Box>
522
+ <Text>{item.text}</Text>
523
+ </Box>
524
+ );
525
+ }
526
+ }
527
+
528
+ function renderAssistantBlock(text: string, theme: ThemeConfig, terminalWidth: number, label: string): React.JSX.Element | null {
529
+ if (!text) return null;
530
+
531
+ return (
532
+ <Box marginTop={1} flexDirection="column">
533
+ <Box>
534
+ <Text color={theme.colors.illusion}>{theme.icons.assistant}</Text>
535
+ <Box marginLeft={1} flexGrow={1}>
536
+ <Text>{'(' + label + ')'}</Text>
537
+ </Box>
538
+ </Box>
539
+ <Box marginLeft={2} flexDirection="column">
540
+ <MarkdownContent text={text} availableWidth={Math.max(MIN_WRAP_WIDTH, terminalWidth - 2 - WIDTH_SAFETY_EXTRA)} />
541
+ </Box>
542
+ </Box>
543
+ );
544
+ }
545
+
546
+
547
+ function renderReasoningBlock(text: string, theme: ThemeConfig, label: string, terminalWidth: number): React.JSX.Element | null {
548
+ if (!text.trim()) return null;
549
+
550
+ return (
551
+ <Box marginTop={1} flexDirection="column">
552
+ <Box>
553
+ <Text color={theme.colors.muted}>● ({label})</Text>
554
+ </Box>
555
+ <Box marginLeft={2} flexDirection="column">
556
+ <MarkdownContent
557
+ text={text}
558
+ style={{color: theme.colors.muted}}
559
+ availableWidth={Math.max(MIN_WRAP_WIDTH, terminalWidth - 2 - WIDTH_SAFETY_EXTRA)}
560
+ />
561
+ </Box>
562
+ </Box>
563
+ );
564
+ }
565
+
566
+ function renderStreamingTail(
567
+ text: string,
568
+ grouped: GroupEntry[],
569
+ theme: ThemeConfig,
570
+ terminalWidth: number,
571
+ ): React.JSX.Element {
572
+ // Filter empty lines to prevent showing golden ● with no text
573
+ const allLines = text.split('\n');
574
+ const lines = allLines.filter(l => l.trim() !== '');
575
+ if (lines.length === 0) return <Box />;
576
+
577
+ const hasOverflow = lines.length > STREAMING_TAIL_LINES;
578
+ const tailCount = hasOverflow ? STREAMING_TAIL_LINES - 1 : STREAMING_TAIL_LINES;
579
+ const tailLines = lines.slice(-tailCount);
580
+
581
+ const lastStaticRole = grouped.length > 0 ? grouped[grouped.length - 1].role : undefined;
582
+ const showIcon = lastStaticRole !== 'assistant' && lastStaticRole !== 'assistant_streaming';
583
+
584
+ return (
585
+ <Box marginTop={1} flexDirection="column">
586
+ {lines.length > STREAMING_TAIL_LINES ? (
587
+ <Box marginLeft={2}>
588
+ <Text dimColor>… {lines.length - STREAMING_TAIL_LINES} lines above</Text>
589
+ </Box>
590
+ ) : null}
591
+ {tailLines.map((line, i) => {
592
+ const isFirst = i === 0 && showIcon;
593
+ const prefixWidth = isFirst ? stringWidth(`${theme.icons.assistant} `) : 2;
594
+ const maxWidth = Math.max(MIN_WRAP_WIDTH, terminalWidth - prefixWidth - WIDTH_SAFETY_EXTRA);
595
+ const truncated = truncateToDisplayWidth(line, maxWidth);
596
+ return (
597
+ <Box key={i} marginLeft={isFirst ? 0 : 2}>
598
+ {isFirst ? (
599
+ <>
600
+ <Text color={theme.colors.illusion}>{theme.icons.assistant}</Text>
601
+ <Box marginLeft={1} flexGrow={1}>
602
+ <Text>{truncated}</Text>
603
+ </Box>
604
+ </>
605
+ ) : (
606
+ <Text>{truncated}</Text>
607
+ )}
608
+ </Box>
609
+ );
610
+ })}
611
+ </Box>
612
+ );
613
+ }
614
+
615
+ function wrapForPrefix(text: string, terminalWidth: number, prefix: string): string[] {
616
+ const availableWidth = Math.max(MIN_WRAP_WIDTH, terminalWidth - stringWidth(prefix) - WIDTH_SAFETY_EXTRA);
617
+ const sourceLines = text.split('\n');
618
+ const wrapped: string[] = [];
619
+ for (const source of sourceLines) {
620
+ const segments = wrapText(source, availableWidth, {hard: true});
621
+ if (segments.length === 0) {
622
+ wrapped.push('');
623
+ continue;
624
+ }
625
+ wrapped.push(...segments);
626
+ }
627
+ return wrapped.length > 0 ? wrapped : [''];
628
+ }
629
+
630
+ function truncateToDisplayWidth(text: string, maxWidth: number): string {
631
+ if (stringWidth(text) <= maxWidth) {
632
+ return text;
633
+ }
634
+ let result = '';
635
+ let width = 0;
636
+ for (const ch of text) {
637
+ const charWidth = stringWidth(ch);
638
+ if (width + charWidth > Math.max(1, maxWidth - 1)) {
639
+ break;
640
+ }
641
+ result += ch;
642
+ width += charWidth;
643
+ }
644
+ return result + '…';
645
+ }
646
+
647
+ function normalizeTextForCompare(raw: string): string {
648
+ return raw.replace(/\s+/g, ' ').trim();
649
+ }
650
+
651
+ function isTextSubsetOrEqual(a: string, b: string): boolean {
652
+ const normA = normalizeTextForCompare(a);
653
+ const normB = normalizeTextForCompare(b);
654
+ if (!normA || !normB) return false;
655
+ return normA === normB || normA.includes(normB) || normB.includes(normA);
656
+ }
657
+
658
+ function summarizeInput(toolName: string, toolInput?: Record<string, unknown>, fallback?: string): string {
659
+ if (!toolInput) {
660
+ return truncateCommand(fallback ?? '');
661
+ }
662
+
663
+ const lower = toolName.toLowerCase();
664
+
665
+ if ((lower === 'bash' || lower === 'powershell') && toolInput.command) {
666
+ return truncateCommand(String(toolInput.command));
667
+ }
668
+ if ((lower === 'read' || lower === 'fileread' || lower === 'read_file') && (toolInput.path || toolInput.file_path)) {
669
+ return String(toolInput.path ?? toolInput.file_path);
670
+ }
671
+ if ((lower === 'write' || lower === 'filewrite' || lower === 'write_file') && (toolInput.path || toolInput.file_path)) {
672
+ return String(toolInput.path ?? toolInput.file_path);
673
+ }
674
+ if ((lower === 'edit' || lower === 'fileedit' || lower === 'edit_file') && (toolInput.path || toolInput.file_path)) {
675
+ return String(toolInput.path ?? toolInput.file_path);
676
+ }
677
+ if (lower === 'grep' && toolInput.pattern) {
678
+ return `/${String(toolInput.pattern)}/`;
679
+ }
680
+ if (lower === 'glob' && toolInput.pattern) {
681
+ return String(toolInput.pattern);
682
+ }
683
+ if (lower === 'agent' && toolInput.description) {
684
+ return truncateCommand(String(toolInput.description));
685
+ }
686
+ if (lower === 'todowrite' || lower === 'todo_write') {
687
+ const todos = toolInput.todos;
688
+ if (Array.isArray(todos)) {
689
+ const total = todos.length;
690
+ const completed = todos.filter((t: {status: string}) => t.status === 'completed').length;
691
+ return `${completed}/${total} tasks`;
692
+ }
693
+ }
694
+ if (lower === 'ask_user_question') {
695
+ const questions = toolInput.questions;
696
+ if (Array.isArray(questions) && questions.length > 0) {
697
+ const q = questions[0] as Record<string, unknown>;
698
+ return truncateCommand(String(q.question ?? ''));
699
+ }
700
+ }
701
+
702
+ const entries = Object.entries(toolInput);
703
+ if (entries.length > 0) {
704
+ const [key, val] = entries[0];
705
+ return truncateCommand(`${key}=${String(val)}`);
706
+ }
707
+
708
+ return truncateCommand(fallback ?? '');
709
+ }
710
+
711
+ // 参考 claude-code 的截断策略:先按行截断,再按字符截断
712
+ function truncateCommand(str: string): string {
713
+ // 1. 按行分割
714
+ const lines = str.split('\n');
715
+
716
+ // 2. 移除每行首尾空格,过滤空行
717
+ const cleanedLines = lines.map(l => l.trim()).filter(l => l.length > 0);
718
+
719
+ // 3. 按行截断(最多 MAX_COMMAND_LINES 行)
720
+ const truncatedLines = cleanedLines.length > MAX_COMMAND_LINES
721
+ ? [...cleanedLines.slice(0, MAX_COMMAND_LINES)]
722
+ : cleanedLines;
723
+
724
+ // 4. 合并为单行
725
+ let result = truncatedLines.join(' ');
726
+
727
+ // 5. 按字符截断
728
+ const needsCharTruncation = result.length > MAX_COMMAND_CHARS || cleanedLines.length > MAX_COMMAND_LINES;
729
+ if (needsCharTruncation && result.length > MAX_COMMAND_CHARS) {
730
+ result = result.slice(0, MAX_COMMAND_CHARS);
731
+ // 优先在分号处截断(命令分隔符)
732
+ const lastSemicolon = result.lastIndexOf(';');
733
+ if (lastSemicolon > MAX_COMMAND_CHARS * 0.3) {
734
+ result = result.slice(0, lastSemicolon + 1);
735
+ } else {
736
+ // 其次在空格处截断
737
+ const lastSpace = result.lastIndexOf(' ');
738
+ if (lastSpace > MAX_COMMAND_CHARS * 0.5) {
739
+ result = result.slice(0, lastSpace);
740
+ }
741
+ }
742
+ }
743
+
744
+ // 6. 添加省略号
745
+ if (needsCharTruncation) {
746
+ result += '…';
747
+ }
748
+
749
+ return result;
750
+ }