osai-agent 4.0.0

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 (86) hide show
  1. package/LICENSE +7 -0
  2. package/package.json +72 -0
  3. package/src/agent/context.js +141 -0
  4. package/src/agent/loop/context-summary.js +196 -0
  5. package/src/agent/loop/directory-utils.js +102 -0
  6. package/src/agent/loop/local.js +196 -0
  7. package/src/agent/loop/loop-detection.js +288 -0
  8. package/src/agent/loop/stream-parser.js +515 -0
  9. package/src/agent/loop/tool-executor.js +470 -0
  10. package/src/agent/loop/verification.js +263 -0
  11. package/src/agent/loop/websocket.js +80 -0
  12. package/src/agent/prompt.js +259 -0
  13. package/src/agent/react-loop.js +697 -0
  14. package/src/agent/subagent.js +263 -0
  15. package/src/commands/config.js +53 -0
  16. package/src/commands/connect.js +190 -0
  17. package/src/commands/devices.js +121 -0
  18. package/src/commands/login.js +77 -0
  19. package/src/commands/logout.js +31 -0
  20. package/src/commands/mcp.js +258 -0
  21. package/src/commands/provider.js +633 -0
  22. package/src/commands/register.js +74 -0
  23. package/src/commands/run.js +150 -0
  24. package/src/commands/search.js +64 -0
  25. package/src/commands/session.js +57 -0
  26. package/src/commands/skills.js +54 -0
  27. package/src/commands/stop-subagent.js +58 -0
  28. package/src/index.js +208 -0
  29. package/src/llm/direct.js +317 -0
  30. package/src/memory/store.js +215 -0
  31. package/src/mock-readline.js +27 -0
  32. package/src/parser/dependencies.js +71 -0
  33. package/src/parser/markdown.js +505 -0
  34. package/src/parser/stream.js +96 -0
  35. package/src/prompts/modes/CODING.js +160 -0
  36. package/src/prompts/modes/GENERAL.js +105 -0
  37. package/src/prompts/modes/NETWORK.js +69 -0
  38. package/src/prompts/modes/SSH.js +53 -0
  39. package/src/prompts/systemPrompt.js +85 -0
  40. package/src/safety/check.js +210 -0
  41. package/src/services/crypto.js +78 -0
  42. package/src/services/executor.js +68 -0
  43. package/src/services/history.js +58 -0
  44. package/src/services/server-url.js +11 -0
  45. package/src/services/session.js +194 -0
  46. package/src/services/ssh.js +176 -0
  47. package/src/services/websocket.js +112 -0
  48. package/src/skills/loader.js +231 -0
  49. package/src/tools/browser.js +434 -0
  50. package/src/tools/local.js +1254 -0
  51. package/src/tools/mcp-client.js +209 -0
  52. package/src/tools/registry.js +132 -0
  53. package/src/tools/search-providers.js +237 -0
  54. package/src/tools/ssh.js +74 -0
  55. package/src/ui/App.js +2031 -0
  56. package/src/ui/animation.js +47 -0
  57. package/src/ui/components/AskUserDialog.js +33 -0
  58. package/src/ui/components/ConfirmationDialog.js +45 -0
  59. package/src/ui/components/DiffView.js +201 -0
  60. package/src/ui/components/Header.js +157 -0
  61. package/src/ui/components/HistoryPicker.js +130 -0
  62. package/src/ui/components/InputShell.js +22 -0
  63. package/src/ui/components/MessageHistory.js +1200 -0
  64. package/src/ui/components/ModalPanel.js +40 -0
  65. package/src/ui/components/ModePicker.js +161 -0
  66. package/src/ui/components/PlanDialog.js +48 -0
  67. package/src/ui/components/ProviderMenu.js +1095 -0
  68. package/src/ui/components/SavePicker.js +106 -0
  69. package/src/ui/components/SelectMenu.js +194 -0
  70. package/src/ui/components/SlashMenu.js +168 -0
  71. package/src/ui/components/SubagentPanel.js +138 -0
  72. package/src/ui/components/TextInputSafe.js +117 -0
  73. package/src/ui/components/TodoPanel.js +54 -0
  74. package/src/ui/components/ToolExecution.js +261 -0
  75. package/src/ui/components/TranscriptViewport.js +99 -0
  76. package/src/ui/diff.js +249 -0
  77. package/src/ui/h.js +7 -0
  78. package/src/ui/mouse-scroll.js +63 -0
  79. package/src/ui/slash-picker.js +58 -0
  80. package/src/ui/terminal.js +41 -0
  81. package/src/ui/theme.js +5 -0
  82. package/src/ui/welcome.js +12 -0
  83. package/src/utils/constants.js +231 -0
  84. package/src/utils/helpers.js +154 -0
  85. package/src/utils/logger.js +81 -0
  86. package/src/utils/sound.js +33 -0
@@ -0,0 +1,1200 @@
1
+ import React, { useMemo, useRef } from 'react';
2
+ import { Box, Text, Static, useWindowSize } from 'ink';
3
+ import { h } from '../h.js';
4
+ import chalk from 'chalk';
5
+ import { highlightCode } from '../../parser/markdown.js';
6
+ import { EditFileDiff, NewFileDiff, AppendFileDiff, DeleteFileDiff } from './DiffView.js';
7
+ import { ENABLE_UI_ANIMATIONS, useAnimationFrame } from '../animation.js';
8
+ import { SubagentPanel } from './SubagentPanel.js';
9
+
10
+ const stripAnsi = (s) => (s || '').replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, '');
11
+ const visibleLen = (s) => stripAnsi(s).length;
12
+
13
+ // Version légère de renderInline pour les cellules de tableau :
14
+ // - Inline code sans padding latéral (évite les espaces superflus dans les colonnes)
15
+ // - Mêmes couleurs que renderInline() mais sans les espaces autour du code
16
+ const renderCellInline = (text) => {
17
+ const clean = (text || '').replace(/\x1b\[[0-9;]*[mA-Za-z]/g, '');
18
+ return clean
19
+ .replace(/\*\*\*([^*]+)\*\*\*/g, (_, t) => chalk.bold.italic.hex('#c0caf5')(t))
20
+ .replace(/\*\*([^*]+)\*\*/g, (_, t) => chalk.bold.hex('#c0caf5')(t))
21
+ .replace(/\*([^*\n]+)\*/g, (_, t) => chalk.italic.hex('#c0caf5')(t))
22
+ .replace(/`([^`\n]+)`/g, (_, t) => chalk.bgHex('#1a1b26').hex('#7dcfff')(t))
23
+ .replace(/~~([^~]+)~~/g, (_, t) => chalk.strikethrough.hex('#565f89')(t))
24
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) =>
25
+ chalk.underline.hex('#7aa2f7')(label) + chalk.hex('#565f89')(` (${url})`)
26
+ );
27
+ };
28
+
29
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
30
+ const THINKING_DOTS = ['', '.', '..', '...'];
31
+ const WRITING_DOTS = ['', '·', '··', '···'];
32
+
33
+ const WRITE_TOOLS = new Set(['WRITE_FILE', 'EDIT_FILE', 'APPEND_FILE', 'DELETE_FILE', 'MOVE_FILE', 'COPY_FILE', 'CREATE_DIR']);
34
+ const READING_DOTS = ['', '∘', '∘∘', '∘∘∘'];
35
+ const READ_TOOLS = new Set(['READ_FILE', 'LIST_DIR', 'TREE_VIEW', 'FILE_INFO']);
36
+
37
+ function formatDuration(seconds) {
38
+ if (!seconds || seconds < 0) return '0.0s';
39
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
40
+ const mins = Math.floor(seconds / 60);
41
+ const secs = seconds % 60;
42
+ if (mins < 60) return `${mins}m ${secs.toFixed(1)}s`;
43
+ const hours = Math.floor(mins / 60);
44
+ const remainMins = mins % 60;
45
+ return `${hours}h ${remainMins}m ${Math.floor(secs)}s`;
46
+ }
47
+
48
+ const TOOL_COLORS = {
49
+ READ_FILE: '#9ece6a', WRITE_FILE: '#e0af68', EDIT_FILE: '#e0af68',
50
+ APPEND_FILE: '#e0af68', DELETE_FILE: '#f7768e', FETCH_URL: '#7dcfff',
51
+ WEB_SEARCH: '#bb9af7', LOCAL_CMD: '#f7768e', SSH_CMD: '#f7768e',
52
+ GREP: '#73daca', GLOB: '#73daca', SEARCH_FILE: '#73daca',
53
+ LIST_DIR: '#9ece6a', TREE_VIEW: '#9ece6a', FILE_INFO: '#9ece6a',
54
+ CREATE_DIR: '#e0af68', RUN_SCRIPT: '#f7768e', MOVE_FILE: '#e0af68',
55
+ COPY_FILE: '#e0af68', GIT: '#f7768e', BROWSE: '#7dcfff',
56
+ BROWSE_SEARCH: '#bb9af7', BROWSE_EXTRACT: '#7dcfff',
57
+ ASK_USER: '#7aa2f7', PLAN_MODE: '#7dcfff', DIAG_POST_EDIT: '#e0af68',
58
+ TODO_ADD: '#9ece6a', TODO_COMPLETE: '#9ece6a', TODO_UPDATE: '#9ece6a',
59
+ TODO_LIST: '#9ece6a', TODO_CLEAR: '#f7768e',
60
+ SKILL_LIST: '#bb9af7', LOAD_SKILL: '#bb9af7', CREATE_SKILL: '#bb9af7', TASK: '#7dcfff',
61
+ };
62
+
63
+ const DEFAULT_COLOR = '#e0af68';
64
+ const LONG_OUTPUT_MAX_LINES = 40;
65
+ const LONG_OUTPUT_MAX_CHARS = 5000;
66
+ const ABSOLUTE_OUTPUT_MAX_CHARS = Math.max(6000, Number.parseInt(process.env.OSAI_UI_MAX_OUTPUT_CHARS || '16000', 10) || 16000);
67
+ const MAX_TEXT_RENDER_CHARS = Math.max(4000, Number.parseInt(process.env.OSAI_UI_MAX_TEXT_CHARS || '12000', 10) || 12000);
68
+ const MAX_TEXT_RENDER_LINES = Math.max(80, Number.parseInt(process.env.OSAI_UI_MAX_TEXT_LINES || '180', 10) || 180);
69
+ const MAX_RENDER_EVENTS = Math.max(10, Number.parseInt(process.env.OSAI_UI_MAX_EVENTS || '28', 10) || 28);
70
+ const PLAIN_OUTPUT_TOOLS = new Set(['LOCAL_CMD', 'SSH_CMD']);
71
+ const TOOL_JSON_LINE_RE = /tool":|^\{"tool|^\{\\"tool\\":|^"(?:path|cmd|url|query|find|replace|content|description|question)":|","[A-Za-z_]+":|"[A-Za-z_]+":\s*[[{]/;
72
+ const INTERNAL_UI_MARKER_RE = /\[(DONE|INCOMPLETE|BLOCKED|TOOL_CALL|TOOL_RESULT)\]/gi;
73
+ const INTERNAL_SSE_LINE_RE = /^\s*(data|event|id|retry):\s.*$/gim;
74
+ const INTERNAL_TOOL_JSON_LINE_RE = /^\s*\{(?:\\")?tool(?:\\")?\s*:\s*.*$/gim;
75
+ const INTERNAL_TOOL_XML_LINE_RE = /<tool\b[^>]*>[\s\S]*?<\/tool\s*>|<tool\b[^>]*\/>/gim;
76
+ const TABLE_SEPARATOR_RE = /^\s*:?-{3,}:?\s*$/;
77
+ const INLINE_PATTERN = /(\[([^\]]+)\]\(([^)\s]+)\))|(`([^`\n]+)`)|(\*\*([^*\n]+)\*\*|__([^_\n]+)__)|(~~([^~\n]+)~~)|(\*([^*\n]+)\*|_([^_\n]+)_)|(https?:\/\/[^\s<>()]+)|(www\.[^\s<>()]+)/g;
78
+ const OSC8_START = '\u001B]8;;';
79
+ const OSC8_END = '\u001B]8;;\u0007';
80
+
81
+ const getToolColor = (name) => TOOL_COLORS[name] || DEFAULT_COLOR;
82
+
83
+ // success: true = past tense (done), false = failed, undefined = in progress
84
+ function getWriteActionLabel(name, toolCall, success) {
85
+ const path = toolCall?.path || '';
86
+ const done = success === true;
87
+ const failed = success === false;
88
+ switch (name) {
89
+ case 'WRITE_FILE':
90
+ if (done) return `Wrote to ${truncate(path, 60)}`;
91
+ if (failed) return `Failed to write ${truncate(path, 60)}`;
92
+ return `Writing to ${truncate(path, 60)}`;
93
+ case 'EDIT_FILE':
94
+ if (done) return `Edited ${truncate(path, 60)}`;
95
+ if (failed) return `Failed to edit ${truncate(path, 60)}`;
96
+ return `Editing ${truncate(path, 60)}`;
97
+ case 'APPEND_FILE':
98
+ if (done) return `Appended to ${truncate(path, 60)}`;
99
+ if (failed) return `Failed to append to ${truncate(path, 60)}`;
100
+ return `Appending to ${truncate(path, 60)}`;
101
+ case 'DELETE_FILE':
102
+ if (done) return `Deleted ${truncate(path, 60)}`;
103
+ if (failed) return `Failed to delete ${truncate(path, 60)}`;
104
+ return `Deleting ${truncate(path, 60)}`;
105
+ case 'CREATE_DIR':
106
+ if (done) return `Created directory ${truncate(path, 60)}`;
107
+ if (failed) return `Failed to create directory ${truncate(path, 60)}`;
108
+ return `Creating directory ${truncate(path, 60)}`;
109
+ case 'MOVE_FILE': {
110
+ const src = toolCall?.source || '';
111
+ const dst = toolCall?.destination || '';
112
+ if (done) return `Moved ${truncate(src, 30)} → ${truncate(dst, 30)}`;
113
+ if (failed) return `Failed to move ${truncate(src, 30)} → ${truncate(dst, 30)}`;
114
+ return `Moving ${truncate(src, 30)} → ${truncate(dst, 30)}`;
115
+ }
116
+ case 'COPY_FILE': {
117
+ const src = toolCall?.source || '';
118
+ const dst = toolCall?.destination || '';
119
+ if (done) return `Copied ${truncate(src, 30)} → ${truncate(dst, 30)}`;
120
+ if (failed) return `Failed to copy ${truncate(src, 30)} → ${truncate(dst, 30)}`;
121
+ return `Copying ${truncate(src, 30)} → ${truncate(dst, 30)}`;
122
+ }
123
+ default: return null;
124
+ }
125
+ }
126
+
127
+ function getReadActionLabel(name, toolCall, success) {
128
+ const path = toolCall?.path || '';
129
+ const done = success === true;
130
+ const failed = success === false;
131
+ switch (name) {
132
+ case 'READ_FILE':
133
+ if (done) return `Read ${truncate(path, 60)}`;
134
+ if (failed) return `Failed to read ${truncate(path, 60)}`;
135
+ return `Reading ${truncate(path, 60)}`;
136
+ case 'LIST_DIR':
137
+ if (done) return `Listed directory ${truncate(path, 60)}`;
138
+ if (failed) return `Failed to list directory ${truncate(path, 60)}`;
139
+ return `Listing directory ${truncate(path, 60)}`;
140
+ case 'TREE_VIEW':
141
+ if (done) return `Viewed tree ${truncate(path, 60)}`;
142
+ if (failed) return `Failed to view tree ${truncate(path, 60)}`;
143
+ return `Viewing tree ${truncate(path, 60)}`;
144
+ case 'FILE_INFO':
145
+ if (done) return `Got info for ${truncate(path, 60)}`;
146
+ if (failed) return `Failed to get info for ${truncate(path, 60)}`;
147
+ return `Getting info for ${truncate(path, 60)}`;
148
+ default: return null;
149
+ }
150
+ }
151
+
152
+ function truncate(value, max = 50) {
153
+ const s = String(value || '');
154
+ if (s.length <= max) return s;
155
+ return s.slice(0, max - 3) + '...';
156
+ }
157
+
158
+ function supportsHyperlinks() {
159
+ if (process.env.OSAI_DISABLE_OSC8 === '1') return false;
160
+ if (process.env.OSAI_ENABLE_OSC8 === '1') return true;
161
+ const term = (process.env.TERM || '').toLowerCase();
162
+ if (term === 'dumb') return false;
163
+ if (process.platform === 'win32') {
164
+ return Boolean(process.env.WT_SESSION || process.env.TERM_PROGRAM || term.includes('xterm'));
165
+ }
166
+ return Boolean(
167
+ process.env.WT_SESSION ||
168
+ process.env.KITTY_WINDOW_ID ||
169
+ process.env.VTE_VERSION ||
170
+ process.env.TERM_PROGRAM ||
171
+ process.env.KONSOLE_VERSION
172
+ );
173
+ }
174
+
175
+ function normalizeUrl(raw) {
176
+ const input = String(raw || '').trim().replace(/[),.;]+$/, '');
177
+ if (!input) return null;
178
+ const candidate = /^www\./i.test(input) ? `https://${input}` : input;
179
+ try {
180
+ const parsed = new URL(candidate);
181
+ if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) return null;
182
+ return parsed.toString();
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ function linkText(label, rawUrl) {
189
+ const safeUrl = normalizeUrl(rawUrl);
190
+ if (!safeUrl) return label;
191
+ if (!supportsHyperlinks()) return `${label} (${safeUrl})`;
192
+ return `${OSC8_START}${safeUrl}\u0007${label}${OSC8_END}`;
193
+ }
194
+
195
+ function shortUrl(rawUrl) {
196
+ const normalized = normalizeUrl(rawUrl);
197
+ if (!normalized) return '';
198
+ try {
199
+ const u = new URL(normalized);
200
+ const host = u.hostname.replace(/^www\./i, '');
201
+ const path = (u.pathname || '/').replace(/\/+$/, '') || '/';
202
+ const compactPath = path.length > 24 ? `${path.slice(0, 24)}...` : path;
203
+ return `${host}${compactPath}`;
204
+ } catch {
205
+ return normalized;
206
+ }
207
+ }
208
+
209
+ function splitTableCells(line) {
210
+ const clean = String(line || '').trim();
211
+ const src = clean.startsWith('|') ? clean.slice(1) : clean;
212
+ const body = src.endsWith('|') ? src.slice(0, -1) : src;
213
+ return body.split('|').map(cell => cell.trim());
214
+ }
215
+
216
+ function isTableLine(line) {
217
+ const trimmed = String(line || '').trim();
218
+ if (!trimmed || (trimmed.match(/\|/g) || []).length < 2) return false;
219
+ const cells = splitTableCells(trimmed);
220
+ return cells.length >= 2;
221
+ }
222
+
223
+ function isTableSeparator(line) {
224
+ const cells = splitTableCells(line).filter(Boolean);
225
+ if (!cells.length) return false;
226
+ return cells.every(cell => TABLE_SEPARATOR_RE.test(cell));
227
+ }
228
+
229
+ function formatTarget(tc) {
230
+ if (!tc) return { text: '', url: null };
231
+ if (tc.path) return { text: truncate(tc.path, 60), url: null };
232
+ if (tc.cmd) return { text: truncate(tc.cmd.replace(/\s+/g, ' ').trim(), 60), url: null };
233
+ if (tc.url) {
234
+ const normalized = normalizeUrl(tc.url);
235
+ return { text: truncate(normalized || tc.url, 60), url: normalized };
236
+ }
237
+ if (tc.query) return { text: truncate(tc.query, 60), url: null };
238
+ if (tc.pattern) return { text: truncate(tc.pattern, 60), url: null };
239
+ if (tc.source) return { text: truncate(tc.source, 60), url: null };
240
+ return { text: '', url: null };
241
+ }
242
+
243
+ function inferCodeLang(text) {
244
+ const src = String(text || '').trim();
245
+ if (!src) return '';
246
+ if ((src.startsWith('{') && src.endsWith('}')) || (src.startsWith('[') && src.endsWith(']'))) {
247
+ try {
248
+ JSON.parse(src);
249
+ return 'json';
250
+ } catch {}
251
+ }
252
+ if (/^\s*(Select-|Get-|Set-|Where-Object|ForEach-Object)\b/m.test(src)) return 'powershell';
253
+ if (/^\s*(npm|node|git|cd|ls|cat|grep|sed|awk|chmod|curl|wget)\b/m.test(src) || /^\s*[$#]\s+/.test(src)) return 'bash';
254
+ if (/\b(import\s+.+\s+from|const\s+\w+\s*=|let\s+\w+\s*=|function\s+\w+\(|=>)\b/.test(src)) return 'javascript';
255
+ if (/\b(def\s+\w+\(|class\s+\w+|from\s+\w+\s+import|import\s+\w+)\b/.test(src)) return 'python';
256
+ return '';
257
+ }
258
+
259
+ function isLikelyCode(text) {
260
+ const src = String(text || '');
261
+ if (!src.trim()) return false;
262
+ if (src.includes('\n')) return true;
263
+ if (/^\s*[\[{]/.test(src) && /[\]}]\s*$/.test(src)) return true;
264
+ return /[;{}()[\]=<>]/.test(src);
265
+ }
266
+
267
+ function collapseLongOutput(text) {
268
+ const source = String(text || '');
269
+ if (!source) return { collapsed: false, output: source, hiddenLines: 0, hiddenChars: 0 };
270
+
271
+ const lines = source.split('\n');
272
+ const isLong = lines.length > LONG_OUTPUT_MAX_LINES || source.length > LONG_OUTPUT_MAX_CHARS;
273
+ if (!isLong) return { collapsed: false, output: source, hiddenLines: 0, hiddenChars: 0 };
274
+
275
+ let output = lines.slice(0, LONG_OUTPUT_MAX_LINES).join('\n');
276
+ if (output.length > LONG_OUTPUT_MAX_CHARS) output = output.slice(0, LONG_OUTPUT_MAX_CHARS);
277
+
278
+ const hiddenLines = Math.max(0, lines.length - LONG_OUTPUT_MAX_LINES);
279
+ const hiddenChars = Math.max(0, source.length - output.length);
280
+ return { collapsed: true, output, hiddenLines, hiddenChars };
281
+ }
282
+
283
+ function hardLimitOutput(text) {
284
+ const source = String(text || '');
285
+ if (source.length <= ABSOLUTE_OUTPUT_MAX_CHARS) return { output: source, capped: false };
286
+ const keepHead = Math.min(3000, Math.floor(ABSOLUTE_OUTPUT_MAX_CHARS * 0.25));
287
+ const keepTail = Math.max(1000, ABSOLUTE_OUTPUT_MAX_CHARS - keepHead);
288
+ const hidden = source.length - keepHead - keepTail;
289
+ return {
290
+ output: `${source.slice(0, keepHead)}\n\n... ${hidden} chars hidden for terminal performance ...\n\n${source.slice(-keepTail)}`,
291
+ capped: true,
292
+ };
293
+ }
294
+
295
+ function limitRenderableText(text) {
296
+ const source = String(text || '');
297
+ if (!source) return { text: source, truncated: false };
298
+
299
+ let limited = source;
300
+ let truncatedChars = 0;
301
+ if (limited.length > MAX_TEXT_RENDER_CHARS) {
302
+ const headChars = Math.min(2500, Math.floor(MAX_TEXT_RENDER_CHARS * 0.25));
303
+ const tailChars = Math.max(1000, MAX_TEXT_RENDER_CHARS - headChars);
304
+ truncatedChars = limited.length - headChars - tailChars;
305
+ limited = `${limited.slice(0, headChars)}\n\n... ${truncatedChars} chars hidden for terminal performance ...\n\n${limited.slice(-tailChars)}`;
306
+ }
307
+
308
+ const lines = limited.split('\n');
309
+ if (lines.length > MAX_TEXT_RENDER_LINES) {
310
+ const headLines = Math.min(45, Math.floor(MAX_TEXT_RENDER_LINES * 0.25));
311
+ const tailLines = Math.max(40, MAX_TEXT_RENDER_LINES - headLines);
312
+ const hiddenLines = lines.length - headLines - tailLines;
313
+ limited = [
314
+ ...lines.slice(0, headLines),
315
+ '',
316
+ `... ${hiddenLines} lines hidden for terminal performance ...`,
317
+ '',
318
+ ...lines.slice(-tailLines),
319
+ ].join('\n');
320
+ return { text: limited, truncated: true };
321
+ }
322
+
323
+ return { text: limited, truncated: truncatedChars > 0 };
324
+ }
325
+
326
+ const CodeOutput = React.memo(({ text, color = '#c0caf5' }) => {
327
+ if (!text) return null;
328
+ const lang = inferCodeLang(text);
329
+ return h(Box, { paddingLeft: 4, flexDirection: 'column', marginTop: 1, borderStyle: 'round', borderColor: '#2a2e3f', paddingX: 1 },
330
+ lang ? h(Text, { color: '#565f89' }, lang) : null,
331
+ h(Text, { ansi: true, color }, highlightCode(text, lang))
332
+ );
333
+ });
334
+
335
+ const ToolSpinner = React.memo(({ name, toolCall, done, animate = true }) => {
336
+ const frame = useAnimationFrame(!done && animate);
337
+ const color = getToolColor(name);
338
+ const target = formatTarget(toolCall);
339
+ const writeLabel = WRITE_TOOLS.has(name) ? getWriteActionLabel(name, toolCall, done ? true : undefined) : null;
340
+ const readLabel = READ_TOOLS.has(name) ? getReadActionLabel(name, toolCall, done ? true : undefined) : null;
341
+
342
+ if (writeLabel) {
343
+ const dots = !done && animate && ENABLE_UI_ANIMATIONS ? WRITING_DOTS[frame % WRITING_DOTS.length] : '';
344
+ return h(Box, { paddingLeft: 2, paddingY: 0 },
345
+ h(Text, { color: done ? '#9ece6a' : '#7aa2f7' }, done ? ' ✓ ' : (animate && ENABLE_UI_ANIMATIONS ? ` ${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ` : ' … ')),
346
+ h(Text, { color: '#ffffff' }, '• '),
347
+ h(Text, { color, bold: true }, writeLabel),
348
+ h(Text, { color: '#565f89' }, done ? '' : dots)
349
+ );
350
+ }
351
+
352
+ if (readLabel) {
353
+ const dots = !done && animate && ENABLE_UI_ANIMATIONS ? READING_DOTS[frame % READING_DOTS.length] : '';
354
+ return h(Box, { paddingLeft: 2, paddingY: 0 },
355
+ h(Text, { color: done ? '#9ece6a' : '#7aa2f7' }, done ? ' ✓ ' : (animate && ENABLE_UI_ANIMATIONS ? ` ${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ` : ' … ')),
356
+ h(Text, { color: '#ffffff' }, '• '),
357
+ h(Text, { color, bold: true }, readLabel),
358
+ h(Text, { color: '#ffffff' }, done ? '' : dots)
359
+ );
360
+ }
361
+
362
+ return h(Box, { paddingLeft: 2, paddingY: 0 },
363
+ h(Text, { color: done ? '#9ece6a' : '#7aa2f7' }, done ? ' ✓ ' : (animate && ENABLE_UI_ANIMATIONS ? ` ${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ` : ' … ')),
364
+ h(Text, { color: '#ffffff' }, '• '),
365
+ h(Text, { color, bold: true }, name),
366
+ target.text ? h(Text, { color: '#565f89' }, ': ') : null,
367
+ target.text
368
+ ? target.url
369
+ ? h(Text, { ansi: true, color: '#7dcfff', underline: true }, linkText(target.text, target.url))
370
+ : h(Text, { color: '#565f89' }, target.text)
371
+ : null
372
+ );
373
+ });
374
+
375
+ const ContextSummarySpinner = React.memo(({ summaryEvent, done, animate = true }) => {
376
+ const frame = useAnimationFrame(!done && animate);
377
+ const summarizeCount = Number(summaryEvent?.summarizeCount || 0);
378
+ const keepRecent = Number(summaryEvent?.keepRecent || 0);
379
+
380
+ return h(Box, { paddingLeft: 2, paddingY: 0 },
381
+ h(Text, { color: done ? '#9ece6a' : '#7aa2f7', bold: true }, done ? ' ✓ ' : (animate && ENABLE_UI_ANIMATIONS ? ` ${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ` : ' … ')),
382
+ h(Text, { color: '#ffffff' }, '• '),
383
+ h(Text, { color: '#7dcfff', bold: true }, 'CONTEXT_SUMMARY'),
384
+ h(Text, { color: '#565f89' }, done
385
+ ? `: summarized ${summarizeCount} messages, kept ${keepRecent} recent`
386
+ : `: summarizing ${summarizeCount} older messages...`
387
+ )
388
+ );
389
+ });
390
+
391
+ const ContextSummaryResult = React.memo(({ doneEvent }) => {
392
+ const summarized = Number(doneEvent?.summarizedMessages || 0);
393
+ const remaining = Number(doneEvent?.remainingMessages || 0);
394
+ return h(Box, { paddingLeft: 2, paddingY: 0 },
395
+ h(Text, { color: '#9ece6a', bold: true }, ' ✓ '),
396
+ h(Text, { color: '#ffffff' }, '• '),
397
+ h(Text, { color: '#7dcfff', bold: true }, 'CONTEXT_SUMMARY'),
398
+ h(Text, { color: '#565f89' }, `: completed (${summarized} summarized, ${remaining} messages now in context)`)
399
+ );
400
+ });
401
+
402
+ const SearchResults = React.memo(({ results }) => {
403
+ if (!results || !results.length) return null;
404
+ const compactResults = results.slice(0, 6);
405
+ return h(Box, { flexDirection: 'column', paddingLeft: 4, marginTop: 1 },
406
+ ...compactResults.map((item, idx) => {
407
+ const title = truncate(item.title || 'Untitled', 62);
408
+ const link = normalizeUrl(item.link || item.url || '');
409
+ const pretty = shortUrl(link || '');
410
+ const snippet = truncate((item.snippet || item.description || '').replace(/\s+/g, ' ').trim(), 84);
411
+ return h(Box, { key: `sr_${idx}`, flexDirection: 'column', marginBottom: 0 },
412
+ h(Text, { color: '#c0caf5', bold: true }, `[${idx + 1}] ${title}`),
413
+ link ? h(Text, { ansi: true, color: '#7dcfff', underline: true }, ` ${linkText(pretty || link, link)}`) : null,
414
+ snippet ? h(Text, { color: '#9aa5ce' }, ` ${snippet}`) : null
415
+ );
416
+ }),
417
+ results.length > compactResults.length
418
+ ? h(Text, { color: '#565f89', italic: true }, ` +${results.length - compactResults.length} more result(s)`)
419
+ : null
420
+ );
421
+ });
422
+
423
+ const ToolResultEvent = React.memo(({ name, success, output, toolCall, results, outputIndex, expandedOutputIndexes }) => {
424
+ const color = getToolColor(name);
425
+ const label = success ? '✓' : '✗';
426
+ const labelColor = success ? '#9ece6a' : '#f7768e';
427
+ const target = formatTarget(toolCall);
428
+ const writeLabel = WRITE_TOOLS.has(name) ? getWriteActionLabel(name, toolCall, success) : null;
429
+ const readLabel = READ_TOOLS.has(name) ? getReadActionLabel(name, toolCall, success) : null;
430
+ const out = String(output || '');
431
+
432
+ let display = out;
433
+ let searchResults = Array.isArray(results) ? results : null;
434
+ const isExpanded = outputIndex != null && expandedOutputIndexes && expandedOutputIndexes.has(outputIndex);
435
+
436
+ if (name === 'READ_FILE' && !isExpanded) {
437
+ const lines = out.split('\n');
438
+ const first = lines[0] || '';
439
+ display = `${first} (${Math.max(0, lines.length - 1)} lines)`;
440
+ } else if (!searchResults && (out.startsWith('{') || out.startsWith('['))) {
441
+ try {
442
+ const parsed = JSON.parse(out);
443
+ if (parsed?.results && Array.isArray(parsed.results)) {
444
+ searchResults = parsed.results;
445
+ display = '';
446
+ } else {
447
+ display = JSON.stringify(parsed, null, 2);
448
+ }
449
+ } catch {
450
+ display = out;
451
+ }
452
+ }
453
+ if (Array.isArray(searchResults) && searchResults.length > 0) {
454
+ display = '';
455
+ }
456
+ if ((name === 'LOCAL_CMD' || name === 'SSH_CMD') && success && !display) {
457
+ display = '(command executed successfully — no output)';
458
+ }
459
+
460
+ const collapsedSuccess = !isExpanded && display ? collapseLongOutput(display) : { collapsed: false, output: display, hiddenLines: 0, hiddenChars: 0 };
461
+ const collapsedError = !isExpanded && out ? collapseLongOutput(out) : { collapsed: false, output: out, hiddenLines: 0, hiddenChars: 0 };
462
+ const cappedDisplay = hardLimitOutput(collapsedSuccess.collapsed ? collapsedSuccess.output : display);
463
+ const cappedError = hardLimitOutput(collapsedError.collapsed ? collapsedError.output : out);
464
+ const shownDisplay = cappedDisplay.output;
465
+ const shownError = cappedError.output;
466
+ const renderSuccessAsCode = shownDisplay && !PLAIN_OUTPUT_TOOLS.has(name) && isLikelyCode(shownDisplay);
467
+ const renderErrorAsCode = shownError && !PLAIN_OUTPUT_TOOLS.has(name) && isLikelyCode(shownError);
468
+
469
+ return h(Box, { flexDirection: 'column', paddingLeft: 2, paddingY: 0 },
470
+ h(Box,
471
+ h(Text, { color: labelColor, bold: true }, ` ${label} `),
472
+ h(Text, { color: '#ffffff' }, '• '),
473
+ writeLabel
474
+ ? h(Text, { color }, writeLabel)
475
+ : readLabel
476
+ ? h(Text, { color }, readLabel)
477
+ : h(Text, { color }, name),
478
+ !writeLabel && !readLabel && outputIndex != null ? h(Text, { color: '#565f89' }, ` [#${outputIndex}]`) : null,
479
+ writeLabel || readLabel
480
+ ? null
481
+ : target.text
482
+ ? h(Text, { color: '#565f89' }, ': ')
483
+ : null,
484
+ writeLabel || readLabel
485
+ ? null
486
+ : target.text
487
+ ? target.url
488
+ ? h(Text, { ansi: true, color: '#7dcfff', underline: true }, linkText(target.text, target.url))
489
+ : h(Text, { color: '#565f89' }, target.text)
490
+ : null
491
+ ),
492
+ success && name === 'EDIT_FILE' && toolCall ? h(EditFileDiff, {
493
+ filePath: toolCall.path,
494
+ find: toolCall.find,
495
+ replace: toolCall.replace
496
+ }) : null,
497
+ success && name === 'WRITE_FILE' && toolCall ? h(NewFileDiff, {
498
+ filePath: toolCall.path,
499
+ content: toolCall.content
500
+ }) : null,
501
+ success && name === 'APPEND_FILE' && toolCall ? h(AppendFileDiff, {
502
+ filePath: toolCall.path,
503
+ content: toolCall.content
504
+ }) : null,
505
+ success && name === 'DELETE_FILE' && toolCall ? h(DeleteFileDiff, {
506
+ filePath: toolCall.path
507
+ }) : null,
508
+ searchResults ? h(SearchResults, { results: searchResults }) : null,
509
+ shownDisplay && success && !['EDIT_FILE', 'WRITE_FILE', 'APPEND_FILE', 'DELETE_FILE'].includes(name)
510
+ ? renderSuccessAsCode
511
+ ? h(CodeOutput, { text: shownDisplay, color: '#c0caf5' })
512
+ : h(Box, { paddingLeft: 4 }, h(Text, { color: '#c0caf5' }, shownDisplay))
513
+ : null,
514
+ !success && shownError
515
+ ? renderErrorAsCode
516
+ ? h(CodeOutput, { text: shownError, color: '#f7768e' })
517
+ : h(Box, { paddingLeft: 4 }, h(Text, { color: '#f7768e' }, shownError))
518
+ : null,
519
+ (collapsedSuccess.collapsed || collapsedError.collapsed || cappedDisplay.capped || cappedError.capped) && outputIndex != null
520
+ ? h(Box, { paddingLeft: 4 }, h(Text, { color: '#565f89', italic: true },
521
+ cappedDisplay.capped || cappedError.capped
522
+ ? 'Output display capped to keep the Linux terminal responsive.'
523
+ : `Output collapsed. Type "shows ${outputIndex}" to expand.`
524
+ ))
525
+ : null
526
+ );
527
+ });
528
+
529
+ function parseInline(text) {
530
+ const source = String(text || '');
531
+ if (!source) return [];
532
+ const segments = [];
533
+ let lastIndex = 0;
534
+ let match;
535
+
536
+ INLINE_PATTERN.lastIndex = 0;
537
+ while ((match = INLINE_PATTERN.exec(source)) !== null) {
538
+ if (match.index > lastIndex) {
539
+ segments.push({ type: 'text', content: source.slice(lastIndex, match.index) });
540
+ }
541
+
542
+ if (match[1]) {
543
+ segments.push({ type: 'link', label: match[2], url: match[3] });
544
+ } else if (match[4]) {
545
+ segments.push({ type: 'code', content: match[5] });
546
+ } else if (match[6]) {
547
+ segments.push({ type: 'bold', content: match[7] || match[8] || '' });
548
+ } else if (match[9]) {
549
+ segments.push({ type: 'strike', content: match[10] || '' });
550
+ } else if (match[11]) {
551
+ segments.push({ type: 'italic', content: match[12] || match[13] || '' });
552
+ } else if (match[14]) {
553
+ segments.push({ type: 'link', label: match[14], url: match[14] });
554
+ } else if (match[15]) {
555
+ segments.push({ type: 'link', label: match[15], url: `https://${match[15]}` });
556
+ }
557
+
558
+ lastIndex = INLINE_PATTERN.lastIndex;
559
+ }
560
+
561
+ if (lastIndex < source.length) {
562
+ segments.push({ type: 'text', content: source.slice(lastIndex) });
563
+ }
564
+
565
+ return segments;
566
+ }
567
+
568
+ const InlineText = React.memo(({ text }) => {
569
+ const segments = parseInline(text);
570
+ return h(Text, null, ...segments.map((seg, i) => {
571
+ switch (seg.type) {
572
+ case 'bold':
573
+ return h(Text, { key: i, bold: true }, seg.content);
574
+ case 'italic':
575
+ return h(Text, { key: i, italic: true }, seg.content);
576
+ case 'strike':
577
+ return h(Text, { key: i, strikethrough: true, color: '#565f89' }, seg.content);
578
+ case 'code':
579
+ return h(Text, { key: i, backgroundColor: '#1a1b2e', color: '#f7768e' }, seg.content);
580
+ case 'link':
581
+ return h(Text, { key: i, ansi: true, color: '#7dcfff', underline: true }, linkText(seg.label, seg.url));
582
+ default:
583
+ return h(Text, { key: i }, seg.content);
584
+ }
585
+ }));
586
+ });
587
+
588
+ function renderContent(text) {
589
+ if (!text) return [];
590
+ const sanitizedText = String(text || '')
591
+ .replace(INTERNAL_SSE_LINE_RE, '')
592
+ .replace(INTERNAL_UI_MARKER_RE, '')
593
+ .replace(INTERNAL_TOOL_JSON_LINE_RE, '')
594
+ .replace(INTERNAL_TOOL_XML_LINE_RE, '')
595
+ .replace(/(?:^|\n)\s*```json\s*(?=\n|$)/gi, '\n')
596
+ .replace(/\n{3,}/g, '\n\n');
597
+
598
+ const parts = [];
599
+ const lines = sanitizedText.split('\n');
600
+ let inCodeBlock = false;
601
+ let codeContent = [];
602
+ let codeLang = '';
603
+ let tableRows = [];
604
+
605
+ const flushTable = () => {
606
+ if (!tableRows.length) return;
607
+ parts.push({ type: 'table', rows: [...tableRows] });
608
+ tableRows = [];
609
+ };
610
+
611
+ for (let i = 0; i < lines.length; i++) {
612
+ const line = lines[i];
613
+ const codeFence = line.match(/^```(\w*)/);
614
+
615
+ if (codeFence) {
616
+ flushTable();
617
+ if (inCodeBlock) {
618
+ const raw = codeContent.join('\n');
619
+ if (!(codeLang === 'json' && (raw.includes('"tool"') || raw.includes('"question"')))) {
620
+ parts.push({ type: 'codeBlock', content: raw, lang: codeLang });
621
+ }
622
+ codeContent = [];
623
+ inCodeBlock = false;
624
+ } else {
625
+ inCodeBlock = true;
626
+ codeLang = codeFence[1] || '';
627
+ }
628
+ continue;
629
+ }
630
+
631
+ if (inCodeBlock) {
632
+ codeContent.push(line);
633
+ continue;
634
+ }
635
+
636
+ if (isTableLine(line)) {
637
+ tableRows.push(line);
638
+ continue;
639
+ }
640
+ flushTable();
641
+
642
+ if (line.trim() === '') {
643
+ parts.push({ type: 'empty' });
644
+ continue;
645
+ }
646
+
647
+ const trimmed = line.trim();
648
+ const toolNameMatch = trimmed.match(/^([A-Z_]{3,}):\s*(.*)/);
649
+ if (
650
+ TOOL_JSON_LINE_RE.test(trimmed) ||
651
+ (trimmed.startsWith('{') && trimmed.includes('"tool"')) ||
652
+ (/[:}"]$/.test(trimmed) && /":/.test(trimmed)) ||
653
+ /^\s*[}\]]/.test(trimmed) ||
654
+ trimmed.startsWith('<tool')
655
+ ) {
656
+ continue;
657
+ }
658
+
659
+ if (toolNameMatch) {
660
+ const toolColor = TOOL_COLORS[toolNameMatch[1]] || '#e0af68';
661
+ parts.push({ type: 'toolIndicator', tool: toolNameMatch[1], content: toolNameMatch[2] || '', color: toolColor });
662
+ continue;
663
+ }
664
+
665
+ const headingMatch = trimmed.match(/^(#{1,4})\s+(.+)$/);
666
+ if (headingMatch) {
667
+ parts.push({ type: 'heading', level: headingMatch[1].length, content: headingMatch[2] });
668
+ continue;
669
+ }
670
+
671
+ if (/^[-*_]{3,}\s*$/.test(trimmed)) {
672
+ parts.push({ type: 'hr' });
673
+ continue;
674
+ }
675
+
676
+ const quoteMatch = line.match(/^\s*>\s?(.*)$/);
677
+ if (quoteMatch) {
678
+ parts.push({ type: 'quote', content: quoteMatch[1] });
679
+ continue;
680
+ }
681
+
682
+ const orderedMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
683
+ if (orderedMatch) {
684
+ parts.push({
685
+ type: 'list',
686
+ ordered: true,
687
+ level: Math.floor((orderedMatch[1] || '').length / 2),
688
+ marker: `${orderedMatch[2]}.`,
689
+ content: orderedMatch[3] || ''
690
+ });
691
+ continue;
692
+ }
693
+
694
+ const bulletMatch = line.match(/^(\s*)[-*+]\s+(.*)$/);
695
+ if (bulletMatch) {
696
+ parts.push({
697
+ type: 'list',
698
+ ordered: false,
699
+ level: Math.floor((bulletMatch[1] || '').length / 2),
700
+ marker: '•',
701
+ content: bulletMatch[2] || ''
702
+ });
703
+ continue;
704
+ }
705
+
706
+ parts.push({ type: 'line', content: line });
707
+ }
708
+
709
+ flushTable();
710
+
711
+ if (inCodeBlock && codeContent.join('\n').trim()) {
712
+ parts.push({ type: 'codeBlock', content: codeContent.join('\n'), lang: codeLang });
713
+ }
714
+
715
+ return parts;
716
+ }
717
+
718
+ const TableBlock = React.memo(({ rows }) => {
719
+ // [FIX-3] Limite la hauteur d'une table à 70% de l'écran pour éviter que
720
+ // Ink ne doive traverser des centaines de nodes Text à chaque frame.
721
+ // On tronque les lignes du milieu (en-tête + queue conservés) et on indique
722
+ // le nombre de lignes cachées.
723
+ const { rows: termRows } = useWindowSize();
724
+ const MAX_TABLE_ROWS = termRows ? Math.max(6, Math.floor(termRows * 0.7)) : 20;
725
+
726
+ const nonSeparator = rows.filter(row => !isTableSeparator(row)).map(splitTableCells);
727
+ if (!nonSeparator.length) return null;
728
+
729
+ const colCount = Math.max(...nonSeparator.map(cells => cells.length));
730
+ let normalized = rows.map(row => {
731
+ const cells = splitTableCells(row).slice(0, colCount);
732
+ while (cells.length < colCount) cells.push('');
733
+ return { raw: row, cells };
734
+ });
735
+
736
+ // Tronquer les data rows (en-tête + séparateur + queue préservés).
737
+ // On ne touche pas à la 1ère ligne (header) ni au séparateur qui la suit.
738
+ let hiddenCount = 0;
739
+ if (normalized.length > MAX_TABLE_ROWS) {
740
+ // On conserve: header (idx 0) + separator (idx 1 si présent) + dernières lignes
741
+ const headKeep = Math.min(2, normalized.length); // header + éventuel sep
742
+ const tailKeep = Math.max(0, MAX_TABLE_ROWS - headKeep - 1); // -1 pour la ligne "...N hidden"
743
+ if (tailKeep > 0 && normalized.length - headKeep - tailKeep > 0) {
744
+ hiddenCount = normalized.length - headKeep - tailKeep;
745
+ const head = normalized.slice(0, headKeep);
746
+ const tail = normalized.slice(normalized.length - tailKeep);
747
+ normalized = [...head, { raw: `__HIDDEN_${hiddenCount}__`, cells: Array(colCount).fill('') }, ...tail];
748
+ } else {
749
+ hiddenCount = 0; // table trop petite pour que la troncature soit utile
750
+ }
751
+ }
752
+
753
+ const widths = Array.from({ length: colCount }, () => 3);
754
+ for (const row of normalized) {
755
+ if (isTableSeparator(row.raw) || row.raw.startsWith('__HIDDEN_')) continue;
756
+ for (let i = 0; i < colCount; i++) {
757
+ const rendered = renderCellInline(row.cells[i] || '');
758
+ widths[i] = Math.min(36, Math.max(widths[i], visibleLen(rendered)));
759
+ }
760
+ }
761
+
762
+ const pad = (value, width) => {
763
+ const displayLen = visibleLen(value);
764
+ if (displayLen <= width) return value + ' '.repeat(Math.max(0, width - displayLen));
765
+ const plain = stripAnsi(value);
766
+ const cut = plain.slice(0, Math.max(1, width - 1)) + '…';
767
+ return cut + ' '.repeat(Math.max(0, width - cut.length));
768
+ };
769
+
770
+ return h(Box, { flexDirection: 'column', paddingLeft: 2 },
771
+ ...normalized.map((row, idx) => {
772
+ if (row.raw.startsWith('__HIDDEN_')) {
773
+ const hidden = Number(row.raw.match(/__HIDDEN_(\d+)__/)?.[1] || 0);
774
+ return h(Text, { key: `tbl_hidden_${idx}`, color: '#565f89', italic: true },
775
+ ` … ${hidden} ligne(s) cachée(s) pour fluidité du terminal …`);
776
+ }
777
+ if (isTableSeparator(row.raw)) {
778
+ const sep = widths.map(w => '─'.repeat(w)).join('─┼─');
779
+ return h(Text, { key: `tbl_sep_${idx}`, color: '#2a2e3f' }, ` ${sep}`);
780
+ }
781
+ const content = row.cells.map((cell, i) => {
782
+ const rendered = renderCellInline(cell || '');
783
+ const styled = idx === 0 ? chalk.bold.hex('#7dcfff')(rendered) : chalk.hex('#c0caf5')(rendered);
784
+ return pad(styled, widths[i]);
785
+ }).join(' │ ');
786
+ return h(Text, { key: `tbl_row_${idx}`, ansi: true }, ` ${content}`);
787
+ })
788
+ );
789
+ });
790
+
791
+ const AnimatedToolIndicator = React.memo(({ tool, content, color, events, animate = true }) => {
792
+ const startCount = events?.filter(ev => ev.type === 'tool_start' && ev.name === tool).length ?? 0;
793
+ const endCount = events?.filter(ev => ev.type === 'tool_end' && ev.name === tool).length ?? 0;
794
+ const isAnimating = animate && startCount > endCount;
795
+ const frame = useAnimationFrame(isAnimating);
796
+
797
+ return h(Box, { paddingLeft: 2 },
798
+ h(Text, { color: isAnimating ? '#7aa2f7' : '#9ece6a', bold: true }, isAnimating ? (ENABLE_UI_ANIMATIONS ? ` ${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ` : ' … ') : ' ✓ '),
799
+ h(Text, { color: '#ffffff' }, '• '),
800
+ h(Text, { color, bold: true }, tool),
801
+ content ? h(Text, { color: '#565f89' }, `: ${content}`) : null
802
+ );
803
+ });
804
+
805
+ // [FIX-5] ThoughtHeader est wrappé dans React.memo : il s'anime via useAnimationFrame
806
+ // et ne doit pas se re-rendre quand d'autres events du EventList changent.
807
+ const ThoughtHeader = React.memo(function ThoughtHeader({ thoughtStreaming, animate = true }) {
808
+ const frame = useAnimationFrame(thoughtStreaming && animate);
809
+
810
+ if (!thoughtStreaming) {
811
+ return h(Text, { color: '#565f89', bold: true }, '[THOUGHT]');
812
+ }
813
+ const dots = animate && ENABLE_UI_ANIMATIONS ? THINKING_DOTS[frame % THINKING_DOTS.length].padEnd(3, ' ') : '...';
814
+ return h(Text, { color: '#565f89', bold: true }, `Thinking${dots}`);
815
+ });
816
+
817
+ const TextContent = React.memo(({ content, events, animate = true }) => {
818
+ const limited = useMemo(() => limitRenderableText(content), [content]);
819
+ const parts = useMemo(() => renderContent(limited.text), [limited.text]);
820
+ if (!content) return null;
821
+
822
+ return h(Box, { flexDirection: 'column' },
823
+ ...parts.map((part, i) => {
824
+ switch (part.type) {
825
+ case 'codeBlock':
826
+ return h(Box, { key: i, flexDirection: 'column', paddingX: 1, marginY: 1, borderStyle: 'round', borderColor: '#2a2e3f' },
827
+ part.lang ? h(Text, { color: '#565f89' }, part.lang) : null,
828
+ h(Text, { ansi: true }, highlightCode(part.content, part.lang))
829
+ );
830
+ case 'table':
831
+ return h(TableBlock, { key: i, rows: part.rows });
832
+ case 'empty':
833
+ return h(Box, { key: i, height: 1 });
834
+ case 'toolIndicator':
835
+ return h(AnimatedToolIndicator, { key: i, tool: part.tool, content: part.content, color: part.color, events, animate });
836
+ case 'heading':
837
+ return h(Box, { key: i, paddingLeft: 2 },
838
+ part.level === 1 ? h(Text, { color: '#7aa2f7', bold: true }, `# ${part.content}`) :
839
+ part.level === 2 ? h(Text, { color: '#7dcfff', bold: true }, `## ${part.content}`) :
840
+ part.level === 3 ? h(Text, { color: '#ffffff', bold: true }, `● ${part.content}`) :
841
+ h(Text, { color: '#9aa5ce', bold: true }, `#### ${part.content}`)
842
+ );
843
+ case 'hr':
844
+ return h(Box, { key: i, paddingLeft: 2 }, h(Text, { color: '#2a2e3f' }, '────────────────────────────────────────'));
845
+ case 'quote':
846
+ return h(Box, { key: i, paddingLeft: 2 },
847
+ h(Text, { color: '#565f89' }, '┃ '),
848
+ h(InlineText, { text: part.content })
849
+ );
850
+ case 'list':
851
+ return h(Box, { key: i, paddingLeft: 2 + (part.level * 2) },
852
+ h(Text, { color: part.ordered ? '#7aa2f7' : '#34d399' }, `${part.marker} `),
853
+ h(InlineText, { text: part.content })
854
+ );
855
+ case 'line':
856
+ return h(Box, { key: i, paddingLeft: 2 }, h(InlineText, { text: part.content }));
857
+ default:
858
+ return null;
859
+ }
860
+ }),
861
+ limited.truncated
862
+ ? h(Box, { paddingLeft: 2 }, h(Text, { color: '#565f89', italic: true }, 'Display shortened to keep the Linux terminal responsive.'))
863
+ : null
864
+ );
865
+ });
866
+
867
+ function hasMatchingToolEnd(events, startIndex, toolEvent) {
868
+ const later = events.slice(startIndex + 1);
869
+ return later.some(ev => {
870
+ if (ev.type !== 'tool_end') return false;
871
+ if (toolEvent.id != null) return ev.id === toolEvent.id;
872
+ return ev.name === toolEvent.name;
873
+ });
874
+ }
875
+
876
+ function hasMatchingContextSummaryEnd(events, startIndex, summaryEvent) {
877
+ const later = events.slice(startIndex + 1);
878
+ return later.some(ev => ev.type === 'context_summary_end' && ev.id === summaryEvent.id);
879
+ }
880
+
881
+ function EventSeparator() {
882
+ return h(Box, { flexDirection: 'column', paddingY: 0 },
883
+ h(Text, { color: '#2a2e3f' }, ' o─────────────────────────────────────────o')
884
+ );
885
+ }
886
+
887
+ // [FIX-collapsible-thought] Une pensée = un bloc pliable. Replié par défaut
888
+ // (gain de perf : on ne rend pas le contenu texte). Ctrl+O (handler dans App.js)
889
+ // toggler l'état plié/déplié via expandedThoughtIds.
890
+ // Quand replié et streaming : spinner animé. Quand replié et terminé : ligne
891
+ // statique ▸ Thought. Aucun texte n'est rendu dans l'état replié.
892
+ //
893
+ // IMPORTANT : tous les hooks sont appelés en haut, sans condition. Le rendu
894
+ // animé (useAnimationFrame) n'est invoqué que quand replié+streaming ; mais
895
+ // pour respecter l'ordre des hooks entre deux rendus, on appelle TOUJOURS
896
+ // useAnimationFrame en haut, puis on choisit la sortie en fonction des flags.
897
+ const CollapsibleThought = React.memo(function CollapsibleThought({ id, content, expanded, streaming, animate = true }) {
898
+ const frame = useAnimationFrame(!expanded && streaming && animate);
899
+
900
+ if (expanded) {
901
+ return h(Box, { key: `thought_${id}`, flexDirection: 'column', paddingY: 1, paddingLeft: 2 },
902
+ h(ThoughtHeader, { thoughtStreaming: streaming, animate }),
903
+ h(Box, { marginTop: 1, paddingLeft: 2 }, h(Text, { color: '#565f89' }, content))
904
+ );
905
+ }
906
+ if (streaming) {
907
+ const dots = animate && ENABLE_UI_ANIMATIONS ? THINKING_DOTS[frame % THINKING_DOTS.length].padEnd(3, ' ') : '...';
908
+ return h(Box, { key: `thought_${id}`, paddingLeft: 2, paddingY: 0 },
909
+ h(Text, { color: '#565f89', bold: true }, '▸ Thinking'),
910
+ h(Text, { color: '#565f89' }, dots),
911
+ h(Text, { color: '#2a2e3f' }, ' (Ctrl+O to expand)')
912
+ );
913
+ }
914
+ return h(Box, { key: `thought_${id}`, paddingLeft: 2, paddingY: 0 },
915
+ h(Text, { color: '#565f89' }, '▸ Thought'),
916
+ h(Text, { color: '#2a2e3f' }, ' (Ctrl+O to expand)')
917
+ );
918
+ });
919
+
920
+ function renderEvent(ev, i, events, expandedOutputIndexes, thoughtStreaming, expandedThoughtIds, animate = true, expandedSubagentId, allSubagentEvents) {
921
+ // [FIX-5] thoughtStreaming n'est passé qu'au case 'thought' pour ne pas polluer
922
+ // les props des autres composants et déclencher des re-renders inutiles.
923
+ // [FIX-collapsible-thought] expandedThoughtIds n'est consulté que pour les
924
+ // thoughts ; les autres events n'en ont pas besoin.
925
+ switch (ev.type) {
926
+ case 'thought': {
927
+ const thoughtId = ev.id;
928
+ const expanded = thoughtId != null && expandedThoughtIds && expandedThoughtIds.has(thoughtId);
929
+ return h(CollapsibleThought, { key: `thought_${thoughtId ?? i}`, id: thoughtId, content: ev.content, expanded, streaming: thoughtStreaming, animate });
930
+ }
931
+ case 'text':
932
+ return h(TextContent, { key: i, content: ev.content, events, animate });
933
+ case 'tool_start':
934
+ if (hasMatchingToolEnd(events, i, ev)) return null;
935
+ return h(ToolSpinner, { key: i, name: ev.name, toolCall: ev.toolCall, done: false, animate });
936
+ case 'tool_end':
937
+ return h(ToolResultEvent, {
938
+ key: i,
939
+ name: ev.name,
940
+ success: ev.success,
941
+ output: ev.output,
942
+ toolCall: ev.toolCall,
943
+ results: ev.results,
944
+ outputIndex: ev.outputIndex,
945
+ expandedOutputIndexes
946
+ });
947
+ case 'context_summary_start':
948
+ if (hasMatchingContextSummaryEnd(events, i, ev)) return null;
949
+ return h(ContextSummarySpinner, { key: i, summaryEvent: ev, done: false, animate });
950
+ case 'context_summary_end': {
951
+ const startEvent = [...events].reverse().find(x => x.type === 'context_summary_start' && x.id === ev.id);
952
+ const merged = startEvent ? { ...startEvent, ...ev } : ev;
953
+ return h(ContextSummaryResult, { key: i, doneEvent: merged });
954
+ }
955
+ case 'badge':
956
+ return h(Box, { key: i, paddingY: 1, paddingLeft: 2, flexDirection: 'row' },
957
+ ev.signal === 'ERROR' || ev.signal === 'BLOCKED'
958
+ ? h(Text, { color: 'red' }, 'X')
959
+ : ev.signal === 'INCOMPLETE'
960
+ ? h(Text, { color: '#e0af68' }, 'o Incomplete')
961
+ : h(Text, { color: '#7aa2f7' }, 'o'),
962
+ ev.elapsed ? h(Text, { color: '#414868' }, ` ${formatDuration(ev.elapsed)}`) : null,
963
+ ev.provider && ev.signal !== 'ERROR'
964
+ ? h(Text, { color: '#565f89' }, ` | `)
965
+ : null,
966
+ ev.provider && ev.signal !== 'ERROR'
967
+ ? h(Text, { color: '#9ece6a' }, `${ev.provider}`)
968
+ : null,
969
+ ev.provider && ev.model && ev.signal !== 'ERROR'
970
+ ? h(Text, { color: '#565f89' }, `/${ev.model}`)
971
+ : null
972
+ );
973
+ case 'todos': {
974
+ const list = ev.todos || [];
975
+ if (list.length === 0) return null;
976
+ const items = list.slice(0, 5);
977
+ const more = list.length - 5;
978
+ return h(Box, { key: i, flexDirection: 'column', paddingLeft: 2, paddingY: 0 },
979
+ h(Text, { color: '#7aa2f7', bold: true }, 'Tasks:'),
980
+ ...items.map((t, j) => {
981
+ const isDone = t.status === 'done' || t.status === 'completed';
982
+ const isProgress = t.status === 'in_progress';
983
+ const icon = isDone ? '✓' : isProgress ? '⟳' : '○';
984
+ const color = isDone ? '#73daca' : isProgress ? '#e0af68' : '#565f89';
985
+ return h(Box, { key: j, paddingLeft: 2 },
986
+ h(Text, { color }, `${icon} `),
987
+ h(Text, { color: '#c0caf5' }, t.text || t.description || ''),
988
+ );
989
+ }),
990
+ more > 0 ? h(Text, { key: 'more', color: '#565f89', dimColor: true }, ` … and ${more} more`) : null,
991
+ );
992
+ }
993
+ case 'subagent': {
994
+ const isExpanded = expandedSubagentId === ev.id;
995
+ const subEvents = (allSubagentEvents && ev.id ? allSubagentEvents[ev.id] : null) || [];
996
+ return h(Box, { key: `sub_${ev.id ?? i}`, flexDirection: 'column' },
997
+ h(SubagentPanel, { state: ev, events: subEvents, isExpanded }),
998
+ );
999
+ }
1000
+ default:
1001
+ return null;
1002
+ }
1003
+ }
1004
+
1005
+ export const EventList = React.memo(function EventList({ events, expandedOutputIndexes, thoughtStreaming = false, expandedThoughtIds, animate = true, expandedSubagentId, allSubagentEvents }) {
1006
+ if (!events || events.length === 0) return null;
1007
+ const items = [];
1008
+ let lastEventType = null;
1009
+ for (let i = 0; i < events.length; i++) {
1010
+ const ev = events[i];
1011
+ const el = renderEvent(ev, i, events, expandedOutputIndexes, thoughtStreaming, expandedThoughtIds, animate, expandedSubagentId, allSubagentEvents);
1012
+ if (el !== null) {
1013
+ if (items.length > 0) {
1014
+ const isToolEvent = (t) => t === 'tool_end' || t === 'tool_start';
1015
+ const addSep = isToolEvent(lastEventType) || isToolEvent(ev.type);
1016
+ if (addSep) items.push(h(EventSeparator, { key: `sep_${i}` }));
1017
+ }
1018
+ items.push(el);
1019
+ lastEventType = ev.type;
1020
+ }
1021
+ }
1022
+ return h(Box, { flexDirection: 'column' }, ...items);
1023
+ });
1024
+
1025
+ const USER_MESSAGE_BG = '#3f4251';
1026
+
1027
+ export function UserMessageBlock({ message }) {
1028
+ if (!message) return null;
1029
+ return h(Box, {
1030
+ marginLeft: 1,
1031
+ marginRight: 1,
1032
+ paddingX: 2,
1033
+ backgroundColor: USER_MESSAGE_BG,
1034
+ },
1035
+ h(Text, { color: '#c0caf5' }, message)
1036
+ );
1037
+ }
1038
+
1039
+ function ExchangeSeparator({ columns }) {
1040
+ const width = Math.max(16, (columns || 80) - 1);
1041
+ return h(Box, { flexDirection: 'column', width: '100%' },
1042
+ h(Text, { color: '#2a2e3f' }, '\u2500'.repeat(width))
1043
+ );
1044
+ }
1045
+
1046
+ export const ExchangeBlock = React.memo(function ExchangeBlock({
1047
+ exchange,
1048
+ columns,
1049
+ expandedOutputIndexes,
1050
+ expandedThoughtIds,
1051
+ }) {
1052
+ if (!exchange) return null;
1053
+ return h(Box, { flexDirection: 'column' },
1054
+ h(ExchangeSeparator, { columns }),
1055
+ exchange.user ? h(UserMessageBlock, { message: exchange.user }) : null,
1056
+ exchange.user ? h(Box, { height: 1 }) : null,
1057
+ h(EventList, {
1058
+ events: exchange.events,
1059
+ expandedOutputIndexes,
1060
+ thoughtStreaming: false,
1061
+ paused: false,
1062
+ animate: false,
1063
+ expandedThoughtIds,
1064
+ })
1065
+ );
1066
+ });
1067
+
1068
+ // [FIX-1] EventListWithStatic sépare les events terminés (passés à <Static>, jamais
1069
+ // re-rendus) des events actifs (rendu dynamique normal qui supporte scroll, animations
1070
+ // et `expandedOutputIndexes`). Règles :
1071
+ // - <Static> n'est utilisé que si !paused && !thoughtStreaming
1072
+ // (sinon le dernier event peut muter et Static est one-shot).
1073
+ // - "actif" = un tool_start sans tool_end, OU un context_summary_start sans end,
1074
+ // OU le dernier event si c'est un thought/text (en cours de streaming).
1075
+ // - MAX_RENDER_EVENTS reste le filet de sécurité sur la partie dynamique.
1076
+ export const EventListWithStatic = React.memo(function EventListWithStatic({ events, scrollOffset = 0, expandedOutputIndexes, thoughtStreaming = false, paused = false, expandedThoughtIds, animate = true, expandedSubagentId, allSubagentEvents }) {
1077
+ // [FIX-1 v3] Règle SIMPLIFIÉE : seul le DERNIER event de `events` est en
1078
+ // streaming. Tous les autres sont "complétés" et vont dans <Static>. Cela
1079
+ // évite le bug précédent où on essayait de mettre dans Static les events
1080
+ // entre (tool_start résolu) et (dernier event), mais ces events sont souvent
1081
+ // recréés en interne par `updateLastEvent` (text/thought en streaming
1082
+ // reçoivent un nouveau objet à chaque flush), ce qui faisait sauter le test
1083
+ // de cohérence et obligeait à ré-émettre toute la liste à Static.
1084
+ //
1085
+ // Conséquence : le tool_start sans tool_end (encore en cours) est dans la
1086
+ // zone dynamique, ce qui est correct : il faut qu'il s'anime. Mais
1087
+ // EventList.renderEvent gère déjà le null pour les tool_start qui ont un
1088
+ // tool_end (donc on n'aura pas de doublon).
1089
+ const staticAccumulatorRef = useRef([]);
1090
+ const lastEventsRef = useRef(null);
1091
+
1092
+ if (!events || events.length === 0) {
1093
+ if (staticAccumulatorRef.current.length > 0) {
1094
+ staticAccumulatorRef.current = [];
1095
+ lastEventsRef.current = null;
1096
+ }
1097
+ return null;
1098
+ }
1099
+
1100
+ // Si on ne peut pas utiliser Static en sécurité, retomber sur EventList.
1101
+ // CRITIQUE : on NE RESET PAS l'accumulateur ici. Sinon, pendant le thinking,
1102
+ // on perd la trace des events ajoutés, et au retour on doit tous les
1103
+ // réémettre d'un coup à Static (gros reprint → header qui descend).
1104
+ // L'accumulateur continuera de croître dans la suite du code (section
1105
+ // "Étend l'accumulateur à cut"), indépendamment de canUseStatic.
1106
+ const canUseStatic = !paused && !thoughtStreaming;
1107
+ if (!canUseStatic) {
1108
+ // On rend tout en dynamique, sans Static. L'accumulateur est mis à jour
1109
+ // juste après par le code d'extension ci-dessous.
1110
+ }
1111
+
1112
+ // lastEventsRef : on s'en sert juste pour détecter un changement de référence
1113
+ // complet (= reset, clear, /new, restore). Pour le cas append-only, on
1114
+ // étendra l'accumulateur en append.
1115
+ if (lastEventsRef.current !== events) {
1116
+ // events est une nouvelle référence (cf. flushEventsToState qui fait
1117
+ // setCurrentEvents([...eventsRef.current])). On ne peut pas se fier aux
1118
+ // références d'objets. On se base sur la longueur et on vérifie que
1119
+ // l'accumulateur tient toujours comme préfixe via le contenu (position).
1120
+ // Si la longueur a diminué OU a sauté en avant de plus de 1, on reset.
1121
+ const acc = staticAccumulatorRef.current;
1122
+ const expectedDelta = events.length - (lastEventsRef.current?.length ?? 0);
1123
+ if (expectedDelta < 0 || expectedDelta > 1 || acc.length > events.length) {
1124
+ // Régression, clear, /new, ou restore : reset complet.
1125
+ staticAccumulatorRef.current = [];
1126
+ } else if (acc.length > 0) {
1127
+ // Append d'au plus 1 event. On vérifie que les events d'avant sont
1128
+ // structurellement "les mêmes" (même type et même id quand applicable).
1129
+ // C'est une heuristique : si le type et l'id (ou content pour text/thought
1130
+ // qui n'ont pas d'id) matchent, on considère que c'est le même event.
1131
+ // Note : updateLastEvent recrée l'objet, donc === ne marche pas. Mais le
1132
+ // contenu (type + id) reste stable.
1133
+ let stillConsistent = true;
1134
+ for (let i = 0; i < acc.length; i++) {
1135
+ const a = acc[i];
1136
+ const e = events[i];
1137
+ if (a.type !== e.type) { stillConsistent = false; break; }
1138
+ if (a.id != null && e.id != null && a.id !== e.id) { stillConsistent = false; break; }
1139
+ }
1140
+ if (!stillConsistent) {
1141
+ staticAccumulatorRef.current = [];
1142
+ }
1143
+ }
1144
+ lastEventsRef.current = events;
1145
+ }
1146
+
1147
+ // Calcule le "cut" : tout sauf le DERNIER event. Si events a un seul
1148
+ // élément, completedEvents est vide et tout est dynamique.
1149
+ const cut = Math.max(0, events.length - 1);
1150
+
1151
+ // Étend l'accumulateur seulement s'il manque des events complétés.
1152
+ // `events.slice(0, cut)` crée un nouveau tableau, mais on ne le fait que
1153
+ // quand la longueur a augmenté.
1154
+ if (staticAccumulatorRef.current.length < cut) {
1155
+ staticAccumulatorRef.current = events.slice(0, cut);
1156
+ }
1157
+
1158
+ // Les events subagent sont exclus du Static (besoin d'interactivité Ctrl+A
1159
+ // pour expand/replier le SubagentPanel). Ils sont prepend à activeEvents.
1160
+ const staticSubagentEvents = [];
1161
+ const staticOtherEvents = [];
1162
+ for (const ev of staticAccumulatorRef.current) {
1163
+ if (ev.type === 'subagent') {
1164
+ staticSubagentEvents.push(ev);
1165
+ } else {
1166
+ staticOtherEvents.push(ev);
1167
+ }
1168
+ }
1169
+ const completedEvents = staticOtherEvents;
1170
+ const activeEvents = [...staticSubagentEvents, ...events.slice(cut)];
1171
+
1172
+ // Rendu static : on appelle renderEvent avec thoughtStreaming=false pour que
1173
+ // les spinners statiques soient en état "done" (ces events sont terminés).
1174
+ // expandedOutputIndexes n'est PAS passé : un event dans Static ne peut plus
1175
+ // être re-rendu, donc l'expansion est sans effet sur lui (acceptable : un
1176
+ // tool_end figé reste consultable visuellement, juste non-extensible).
1177
+ const renderStatic = (ev, i) => {
1178
+ // expandedThoughtIds=undefined : les events dans Static sont figés, donc
1179
+ // l'état plié/déplié de leurs thoughts est sans effet (toujours plié).
1180
+ const el = renderEvent(ev, i, events, undefined, false, undefined, false, expandedSubagentId, allSubagentEvents);
1181
+ if (el === null) return null;
1182
+ return el;
1183
+ };
1184
+
1185
+ // Si !canUseStatic, on rend tout en dynamique (sans Static). L'accumulateur
1186
+ // a quand même été étendu ci-dessus, donc au retour à canUseStatic=true,
1187
+ // Static reprendra avec un accumulateur déjà à jour, sans reprint.
1188
+ if (!canUseStatic) {
1189
+ return h(EventList, { events, scrollOffset, expandedOutputIndexes, thoughtStreaming, paused, expandedThoughtIds, animate, expandedSubagentId, allSubagentEvents });
1190
+ }
1191
+
1192
+ return h(Box, { flexDirection: 'column' },
1193
+ completedEvents.length > 0
1194
+ ? h(Static, { items: completedEvents }, renderStatic)
1195
+ : null,
1196
+ activeEvents.length > 0
1197
+ ? h(EventList, { events: activeEvents, scrollOffset, expandedOutputIndexes, thoughtStreaming, paused, expandedThoughtIds, animate, expandedSubagentId, allSubagentEvents })
1198
+ : null
1199
+ );
1200
+ });