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.
- package/LICENSE +7 -0
- package/package.json +72 -0
- package/src/agent/context.js +141 -0
- package/src/agent/loop/context-summary.js +196 -0
- package/src/agent/loop/directory-utils.js +102 -0
- package/src/agent/loop/local.js +196 -0
- package/src/agent/loop/loop-detection.js +288 -0
- package/src/agent/loop/stream-parser.js +515 -0
- package/src/agent/loop/tool-executor.js +470 -0
- package/src/agent/loop/verification.js +263 -0
- package/src/agent/loop/websocket.js +80 -0
- package/src/agent/prompt.js +259 -0
- package/src/agent/react-loop.js +697 -0
- package/src/agent/subagent.js +263 -0
- package/src/commands/config.js +53 -0
- package/src/commands/connect.js +190 -0
- package/src/commands/devices.js +121 -0
- package/src/commands/login.js +77 -0
- package/src/commands/logout.js +31 -0
- package/src/commands/mcp.js +258 -0
- package/src/commands/provider.js +633 -0
- package/src/commands/register.js +74 -0
- package/src/commands/run.js +150 -0
- package/src/commands/search.js +64 -0
- package/src/commands/session.js +57 -0
- package/src/commands/skills.js +54 -0
- package/src/commands/stop-subagent.js +58 -0
- package/src/index.js +208 -0
- package/src/llm/direct.js +317 -0
- package/src/memory/store.js +215 -0
- package/src/mock-readline.js +27 -0
- package/src/parser/dependencies.js +71 -0
- package/src/parser/markdown.js +505 -0
- package/src/parser/stream.js +96 -0
- package/src/prompts/modes/CODING.js +160 -0
- package/src/prompts/modes/GENERAL.js +105 -0
- package/src/prompts/modes/NETWORK.js +69 -0
- package/src/prompts/modes/SSH.js +53 -0
- package/src/prompts/systemPrompt.js +85 -0
- package/src/safety/check.js +210 -0
- package/src/services/crypto.js +78 -0
- package/src/services/executor.js +68 -0
- package/src/services/history.js +58 -0
- package/src/services/server-url.js +11 -0
- package/src/services/session.js +194 -0
- package/src/services/ssh.js +176 -0
- package/src/services/websocket.js +112 -0
- package/src/skills/loader.js +231 -0
- package/src/tools/browser.js +434 -0
- package/src/tools/local.js +1254 -0
- package/src/tools/mcp-client.js +209 -0
- package/src/tools/registry.js +132 -0
- package/src/tools/search-providers.js +237 -0
- package/src/tools/ssh.js +74 -0
- package/src/ui/App.js +2031 -0
- package/src/ui/animation.js +47 -0
- package/src/ui/components/AskUserDialog.js +33 -0
- package/src/ui/components/ConfirmationDialog.js +45 -0
- package/src/ui/components/DiffView.js +201 -0
- package/src/ui/components/Header.js +157 -0
- package/src/ui/components/HistoryPicker.js +130 -0
- package/src/ui/components/InputShell.js +22 -0
- package/src/ui/components/MessageHistory.js +1200 -0
- package/src/ui/components/ModalPanel.js +40 -0
- package/src/ui/components/ModePicker.js +161 -0
- package/src/ui/components/PlanDialog.js +48 -0
- package/src/ui/components/ProviderMenu.js +1095 -0
- package/src/ui/components/SavePicker.js +106 -0
- package/src/ui/components/SelectMenu.js +194 -0
- package/src/ui/components/SlashMenu.js +168 -0
- package/src/ui/components/SubagentPanel.js +138 -0
- package/src/ui/components/TextInputSafe.js +117 -0
- package/src/ui/components/TodoPanel.js +54 -0
- package/src/ui/components/ToolExecution.js +261 -0
- package/src/ui/components/TranscriptViewport.js +99 -0
- package/src/ui/diff.js +249 -0
- package/src/ui/h.js +7 -0
- package/src/ui/mouse-scroll.js +63 -0
- package/src/ui/slash-picker.js +58 -0
- package/src/ui/terminal.js +41 -0
- package/src/ui/theme.js +5 -0
- package/src/ui/welcome.js +12 -0
- package/src/utils/constants.js +231 -0
- package/src/utils/helpers.js +154 -0
- package/src/utils/logger.js +81 -0
- 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
|
+
});
|