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
package/src/ui/App.js
ADDED
|
@@ -0,0 +1,2031 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { Box, Text, useWindowSize, useInput } from 'ink';
|
|
3
|
+
import TextInput from './components/TextInputSafe.js';
|
|
4
|
+
import { h } from './h.js';
|
|
5
|
+
import { Header, getHeaderRows } from './components/Header.js';
|
|
6
|
+
import { EventList } from './components/MessageHistory.js';
|
|
7
|
+
import { TranscriptViewport } from './components/TranscriptViewport.js';
|
|
8
|
+
import { setupMouseScroll } from './mouse-scroll.js';
|
|
9
|
+
import { SlashMenu } from './components/SlashMenu.js';
|
|
10
|
+
import { ModePicker } from './components/ModePicker.js';
|
|
11
|
+
import { ProviderMenu } from './components/ProviderMenu.js';
|
|
12
|
+
import { getLocalProviderConfig } from '../llm/direct.js';
|
|
13
|
+
import { ConfirmationDialog } from './components/ConfirmationDialog.js';
|
|
14
|
+
import { AskUserDialog } from './components/AskUserDialog.js';
|
|
15
|
+
import { PlanDialog } from './components/PlanDialog.js';
|
|
16
|
+
import { MockReadline } from '../mock-readline.js';
|
|
17
|
+
import { LOGO_LINES, TAGLINE, VERSION } from './welcome.js';
|
|
18
|
+
import { executeSlashCommand } from './slash-picker.js';
|
|
19
|
+
import { SessionManager } from '../services/session.js';
|
|
20
|
+
import { subscribeToLogs, logger } from '../utils/logger.js';
|
|
21
|
+
import { playSystemBeepIfInactive } from '../utils/sound.js';
|
|
22
|
+
import { SavePicker } from './components/SavePicker.js';
|
|
23
|
+
import { HistoryPicker } from './components/HistoryPicker.js';
|
|
24
|
+
import { ModalPanel } from './components/ModalPanel.js';
|
|
25
|
+
import { InputShell } from './components/InputShell.js';
|
|
26
|
+
import { MAIN_INPUT_BACKGROUND } from './theme.js';
|
|
27
|
+
import pkg from 'node-machine-id';
|
|
28
|
+
const { machineIdSync } = pkg;
|
|
29
|
+
import { decrypt, deriveKey } from '../services/crypto.js';
|
|
30
|
+
import Conf from 'conf';
|
|
31
|
+
import chalk from 'chalk';
|
|
32
|
+
import boxen from 'boxen';
|
|
33
|
+
import { ENABLE_UI_ANIMATIONS, useAnimationFrame } from './animation.js';
|
|
34
|
+
|
|
35
|
+
function TaskSeparator({ label, columns }) {
|
|
36
|
+
const width = Math.max(16, (columns || 80) - 1);
|
|
37
|
+
const line = '─'.repeat(width);
|
|
38
|
+
|
|
39
|
+
return h(Box, { flexDirection: 'column', width: '100%' },
|
|
40
|
+
h(Text, { color: '#2a2e3f' }, line),
|
|
41
|
+
label ? h(Box, { paddingLeft: 2 }, h(Text, { color: '#565f89', dimColor: true }, label)) : null
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let InputFrameLine = ({ columns }) => {
|
|
46
|
+
const line = '─'.repeat(columns || 80);
|
|
47
|
+
return h(Text, { color: '#c0caf5' }, line);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const LOADING_SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
51
|
+
const LOADING_DOTS = ['', '∘', '∘∘', '∘∘∘'];
|
|
52
|
+
|
|
53
|
+
function LoadingDots({ text }) {
|
|
54
|
+
const frame = useAnimationFrame();
|
|
55
|
+
const spinner = ENABLE_UI_ANIMATIONS ? LOADING_SPINNER[frame % LOADING_SPINNER.length] : '⠋';
|
|
56
|
+
const dots = ENABLE_UI_ANIMATIONS ? LOADING_DOTS[frame % LOADING_DOTS.length] : '...';
|
|
57
|
+
return h(Text, { color: '#7aa2f7' }, ` ${spinner} ${text} ${dots}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PULSE_DOTS = ['', '.', '..', '...'];
|
|
61
|
+
const PROCESSING_SWEEP_WIDTH = 4;
|
|
62
|
+
|
|
63
|
+
function formatDuration(seconds) {
|
|
64
|
+
if (!seconds || seconds < 0) return '0.0s';
|
|
65
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
66
|
+
const mins = Math.floor(seconds / 60);
|
|
67
|
+
const secs = seconds % 60;
|
|
68
|
+
if (mins < 60) return `${mins}m ${secs.toFixed(1)}s`;
|
|
69
|
+
const hours = Math.floor(mins / 60);
|
|
70
|
+
const remainMins = mins % 60;
|
|
71
|
+
return `${hours}h ${remainMins}m ${Math.floor(secs)}s`;
|
|
72
|
+
}
|
|
73
|
+
const defaultRenderThrottle = process.platform !== 'win32' && process.stdout.isTTY ? '250' : '150';
|
|
74
|
+
const requestedRenderThrottle = Number.parseInt(process.env.OSAI_UI_RENDER_THROTTLE_MS || defaultRenderThrottle, 10);
|
|
75
|
+
const UI_RENDER_THROTTLE_MS = Number.isFinite(requestedRenderThrottle)
|
|
76
|
+
? Math.max(100, requestedRenderThrottle)
|
|
77
|
+
: 150;
|
|
78
|
+
// Astuce : augmentez OSAI_UI_RENDER_THROTTLE_MS (ex: 2000) pour espacer les rafraîchissements si le terminal tremble encore
|
|
79
|
+
// Ou désactivez les animations : OSAI_UI_ANIMATIONS=0
|
|
80
|
+
const INTERNAL_UI_MARKER_RE = /\[(DONE|INCOMPLETE|BLOCKED|TOOL_CALL|TOOL_RESULT)\]/gi;
|
|
81
|
+
const INTERNAL_SSE_LINE_RE = /^\s*(data|event|id|retry):\s.*$/gim;
|
|
82
|
+
const INTERNAL_TOOL_JSON_LINE_RE = /^\s*\{(?:\\")?tool(?:\\")?\s*:\s*.*$/gim;
|
|
83
|
+
const INTERNAL_TOOL_XML_LINE_RE = /<tool\b[^>]*>[\s\S]*?<\/tool\s*>|<tool\b[^>]*\/>/gim;
|
|
84
|
+
|
|
85
|
+
function sanitizeUiText(input) {
|
|
86
|
+
let text = String(input || '');
|
|
87
|
+
if (!text) return '';
|
|
88
|
+
|
|
89
|
+
text = text
|
|
90
|
+
.replace(INTERNAL_SSE_LINE_RE, '')
|
|
91
|
+
.replace(INTERNAL_UI_MARKER_RE, '')
|
|
92
|
+
.replace(INTERNAL_TOOL_JSON_LINE_RE, '')
|
|
93
|
+
.replace(INTERNAL_TOOL_XML_LINE_RE, '')
|
|
94
|
+
.replace(/(?:^|\n)\s*```json\s*(?=\n|$)/gi, '\n')
|
|
95
|
+
.replace(/\n{3,}/g, '\n\n');
|
|
96
|
+
|
|
97
|
+
return text;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const BAR_FRAMES = (() => {
|
|
101
|
+
const frames = [];
|
|
102
|
+
const W = 10;
|
|
103
|
+
for (let i = 1; i <= W; i++) {
|
|
104
|
+
frames.push('█'.repeat(i) + '░'.repeat(W - i));
|
|
105
|
+
}
|
|
106
|
+
for (let i = W - 1; i >= 1; i--) {
|
|
107
|
+
frames.push('█'.repeat(i) + '░'.repeat(W - i));
|
|
108
|
+
}
|
|
109
|
+
return frames;
|
|
110
|
+
})();
|
|
111
|
+
|
|
112
|
+
function ModeSwitchAnimation({ modeLabel }) {
|
|
113
|
+
const frame = useAnimationFrame();
|
|
114
|
+
return h(Box, { paddingLeft: 2 },
|
|
115
|
+
h(Text, { color: '#7aa2f7' }, `Switching to ${modeLabel} `),
|
|
116
|
+
h(Text, { color: '#9ece6a' }, ENABLE_UI_ANIMATIONS ? BAR_FRAMES[frame % BAR_FRAMES.length] : '##########')
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function LogoutAnimation() {
|
|
121
|
+
const [frame, setFrame] = useState(0);
|
|
122
|
+
const [phase, setPhase] = useState('progress');
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const timer = setInterval(() => {
|
|
125
|
+
setFrame(f => {
|
|
126
|
+
if (f >= BAR_FRAMES.length - 1) {
|
|
127
|
+
clearInterval(timer);
|
|
128
|
+
setPhase('goodbye');
|
|
129
|
+
return f;
|
|
130
|
+
}
|
|
131
|
+
return f + 1;
|
|
132
|
+
});
|
|
133
|
+
}, 70);
|
|
134
|
+
return () => clearInterval(timer);
|
|
135
|
+
}, []);
|
|
136
|
+
if (phase === 'goodbye') {
|
|
137
|
+
return h(Box, { flexDirection: 'column', alignItems: 'center', paddingTop: 2 },
|
|
138
|
+
h(Text, { color: '#7aa2f7', bold: true }, 'OS AI Agent'),
|
|
139
|
+
h(Text, { color: '#c0caf5' }, 'See you soon!'),
|
|
140
|
+
h(Text, { color: '#565f89', dimColor: true }, 'You have been logged out.'),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return h(Box, { paddingLeft: 2, paddingTop: 2 },
|
|
144
|
+
h(Text, { color: '#565f89' }, 'Logging out '),
|
|
145
|
+
h(Text, { color: '#9ece6a' }, ENABLE_UI_ANIMATIONS ? BAR_FRAMES[frame] : '##########'),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function ProcessingPulse({ startAt = 0 }) {
|
|
150
|
+
const frame = useAnimationFrame();
|
|
151
|
+
const dots = ENABLE_UI_ANIMATIONS ? PULSE_DOTS[frame % PULSE_DOTS.length].padEnd(3, ' ') : '...';
|
|
152
|
+
const elapsedSec = startAt ? Math.max(0, (Date.now() - startAt) / 1000) : 0;
|
|
153
|
+
const elapsedLabel = `(${formatDuration(elapsedSec)})`;
|
|
154
|
+
const label = `Processing${dots}`;
|
|
155
|
+
const totalPositions = label.length + PROCESSING_SWEEP_WIDTH;
|
|
156
|
+
const sweepHead = ENABLE_UI_ANIMATIONS
|
|
157
|
+
? Math.floor((elapsedSec * 1000) / 3000 * totalPositions) % totalPositions
|
|
158
|
+
: -1;
|
|
159
|
+
|
|
160
|
+
return h(Box, { paddingLeft: 2 },
|
|
161
|
+
h(Text, { color: '#565f89', italic: true }, `${elapsedLabel} `),
|
|
162
|
+
h(Text, {},
|
|
163
|
+
[...label].map((char, index) => {
|
|
164
|
+
const distance = Math.abs(index - sweepHead);
|
|
165
|
+
const isCore = distance <= 1;
|
|
166
|
+
const isGlow = distance <= PROCESSING_SWEEP_WIDTH;
|
|
167
|
+
return h(Text, {
|
|
168
|
+
key: `processing_sweep_${index}`,
|
|
169
|
+
color: isCore ? '#ffffff' : isGlow ? '#7dcfff' : '#565f89',
|
|
170
|
+
bold: isCore,
|
|
171
|
+
italic: !isCore,
|
|
172
|
+
}, char);
|
|
173
|
+
})
|
|
174
|
+
)
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const mapHistoryToExchanges = (conversationHistory) => {
|
|
179
|
+
if (!Array.isArray(conversationHistory) || conversationHistory.length === 0) return [];
|
|
180
|
+
const normalized = conversationHistory
|
|
181
|
+
.filter((m) => m && typeof m.role === 'string' && typeof m.content === 'string')
|
|
182
|
+
.filter((m) => (m.role === 'user' || m.role === 'assistant') && m.content.trim().length > 0);
|
|
183
|
+
|
|
184
|
+
const exchanges = [];
|
|
185
|
+
let current = null;
|
|
186
|
+
for (const msg of normalized) {
|
|
187
|
+
if (msg.role === 'user') {
|
|
188
|
+
if (current) exchanges.push(current);
|
|
189
|
+
current = { user: msg.content, events: [] };
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!current) current = { user: '', events: [] };
|
|
193
|
+
let content = msg.content;
|
|
194
|
+
// Extract <think> blocks from stored history and route to thought events
|
|
195
|
+
const thinkBlockPattern = /<think\b[^>]*>([\s\S]*?)<\/think\s*>|<think\b[^>]*\/>/gi;
|
|
196
|
+
let thinkMatch;
|
|
197
|
+
while ((thinkMatch = thinkBlockPattern.exec(msg.content)) !== null) {
|
|
198
|
+
const thinkContent = thinkMatch[1];
|
|
199
|
+
if (thinkContent && thinkContent.trim()) {
|
|
200
|
+
current.events.push({ type: 'thought', id: msg.content.indexOf(thinkMatch[0]), content: thinkContent.trim() });
|
|
201
|
+
}
|
|
202
|
+
content = content.replace(thinkMatch[0], '');
|
|
203
|
+
}
|
|
204
|
+
content = content.trim();
|
|
205
|
+
if (content) {
|
|
206
|
+
current.events.push({ type: 'text', content });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (current) exchanges.push(current);
|
|
210
|
+
return exchanges;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const InputField = ({ onSubmit, placeholder, disabled, onActivity }) => {
|
|
214
|
+
const [value, setValue] = useState('');
|
|
215
|
+
const Input = TextInput.default || TextInput;
|
|
216
|
+
return h(InputShell, { flexDirection: 'row', paddingX: 1, paddingY: 0, backgroundColor: MAIN_INPUT_BACKGROUND },
|
|
217
|
+
h(Text, { color: '#7aa2f7', bold: true }, '> '),
|
|
218
|
+
h(Input, {
|
|
219
|
+
value,
|
|
220
|
+
onChange: (val) => {
|
|
221
|
+
setValue(val);
|
|
222
|
+
if (onActivity) onActivity();
|
|
223
|
+
},
|
|
224
|
+
onSubmit: () => {
|
|
225
|
+
if (disabled || !value.trim()) return;
|
|
226
|
+
onSubmit(value);
|
|
227
|
+
setValue('');
|
|
228
|
+
},
|
|
229
|
+
placeholder
|
|
230
|
+
})
|
|
231
|
+
);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const MAX_VISIBLE_EVENTS = Math.max(80, Number.parseInt(process.env.OSAI_UI_MAX_VISIBLE_EVENTS || '200', 10) || 200);
|
|
235
|
+
|
|
236
|
+
const trimEvents = (events, max) => {
|
|
237
|
+
if (events.length <= max) return events;
|
|
238
|
+
const removed = events.length - max + 1;
|
|
239
|
+
return [
|
|
240
|
+
{ type: 'text', content: `\u22EF ${removed} earlier events hidden \u22EF` },
|
|
241
|
+
...events.slice(-max + 1)
|
|
242
|
+
];
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export function App({ createAgentLoop, agentConfig, initialSession = null, onExit }) {
|
|
246
|
+
const [state, setState] = useState('idle');
|
|
247
|
+
const [currentEvents, setCurrentEvents] = useState([]);
|
|
248
|
+
|
|
249
|
+
const [exchanges, setExchanges] = useState([]);
|
|
250
|
+
|
|
251
|
+
const [userMessages, setUserMessages] = useState([]);
|
|
252
|
+
const [todos, setTodos] = useState(null);
|
|
253
|
+
const [subagentState, setSubagentState] = useState(null);
|
|
254
|
+
const [expandedSubagentId, setExpandedSubagentId] = useState(null);
|
|
255
|
+
const [followLive, setFollowLive] = useState(true);
|
|
256
|
+
|
|
257
|
+
const [showSlashMenu, setShowSlashMenu] = useState(false);
|
|
258
|
+
const [showModePicker, setShowModePicker] = useState(false);
|
|
259
|
+
const [executionMode, setExecutionMode] = useState(agentConfig.executionMode || 'EXEC');
|
|
260
|
+
const [showProviderMenu, setShowProviderMenu] = useState(false);
|
|
261
|
+
const [modeSwitching, setModeSwitching] = useState(null);
|
|
262
|
+
const [confirmationPrompt, setConfirmationPrompt] = useState('');
|
|
263
|
+
const [confirmDetails, setConfirmDetails] = useState(null);
|
|
264
|
+
const [askUserDetails, setAskUserDetails] = useState(null);
|
|
265
|
+
const [planDetails, setPlanDetails] = useState(null);
|
|
266
|
+
const [badgeInfo, setBadgeInfo] = useState(null);
|
|
267
|
+
const [hasStarted, setHasStarted] = useState(false);
|
|
268
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
269
|
+
const [currentDirectory, setCurrentDirectory] = useState(process.cwd());
|
|
270
|
+
const deviceDisplayName = agentConfig.device?.name
|
|
271
|
+
? `${agentConfig.device.name} (${agentConfig.device.ip})`
|
|
272
|
+
: null;
|
|
273
|
+
const hiddenModes = agentConfig.device
|
|
274
|
+
? agentConfig.mode === 'SSH' ? ['NETWORK'] : agentConfig.mode === 'NETWORK' ? ['SSH'] : []
|
|
275
|
+
: [];
|
|
276
|
+
const [showSavePicker, setShowSavePicker] = useState(false);
|
|
277
|
+
const [savePendingAction, setSavePendingAction] = useState(null);
|
|
278
|
+
const [showHistoryPicker, setShowHistoryPicker] = useState(false);
|
|
279
|
+
const [loggingOut, setLoggingOut] = useState(false);
|
|
280
|
+
const [historySessions, setHistorySessions] = useState([]);
|
|
281
|
+
const [loadingHistory, setLoadingHistory] = useState(false);
|
|
282
|
+
const [expandedOutputIndexes, setExpandedOutputIndexes] = useState(new Set());
|
|
283
|
+
// [FIX-collapsible-thought] Set des thought.id dépliés. Par défaut vide → tous
|
|
284
|
+
// les thoughts sont pliés (juste une ligne animée). Ctrl+O déplie/replie le
|
|
285
|
+
// dernier thought.
|
|
286
|
+
const [expandedThoughtIds, setExpandedThoughtIds] = useState(new Set());
|
|
287
|
+
const [tokenCount, setTokenCount] = useState(null);
|
|
288
|
+
const { rows, columns } = useWindowSize();
|
|
289
|
+
const [currentProvider, setCurrentProvider] = useState(() => {
|
|
290
|
+
if (agentConfig.local) {
|
|
291
|
+
try {
|
|
292
|
+
const cfg = getLocalProviderConfig();
|
|
293
|
+
if (cfg) return { type: cfg.type, model: cfg.model || null };
|
|
294
|
+
} catch {}
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
});
|
|
298
|
+
const isLocal = !!agentConfig.local;
|
|
299
|
+
const currentProviderRef = useRef(currentProvider);
|
|
300
|
+
useEffect(() => { currentProviderRef.current = currentProvider; }, [currentProvider]);
|
|
301
|
+
const transcriptScrollRef = useRef(null);
|
|
302
|
+
const followLiveRef = useRef(true);
|
|
303
|
+
const [logNotifications, setLogNotifications] = useState([]);
|
|
304
|
+
|
|
305
|
+
const mockReadlineRef = useRef(new MockReadline());
|
|
306
|
+
const agentLoopRef = useRef(null);
|
|
307
|
+
const isModalActiveRef = useRef(false);
|
|
308
|
+
const toolUiIdCounterRef = useRef(0);
|
|
309
|
+
const contextSummaryUiIdCounterRef = useRef(0);
|
|
310
|
+
const thoughtUiIdCounterRef = useRef(0);
|
|
311
|
+
const activeContextSummaryIdRef = useRef(null);
|
|
312
|
+
const outputIndexCounterRef = useRef(0);
|
|
313
|
+
const exchangeStartRef = useRef(0);
|
|
314
|
+
const eventsRef = useRef([]);
|
|
315
|
+
const [subagentEvents, setSubagentEvents] = useState({});
|
|
316
|
+
const uiFlushTimerRef = useRef(null);
|
|
317
|
+
const badgeInfoRef = useRef(null);
|
|
318
|
+
const badgeTimerRef = useRef(null);
|
|
319
|
+
const cancelRequestedRef = useRef(false);
|
|
320
|
+
const agentLoopActiveRef = useRef(false);
|
|
321
|
+
const lastUserActivityRef = useRef(Date.now());
|
|
322
|
+
const pendingToolEndTimersRef = useRef(new Set());
|
|
323
|
+
const stateRef = useRef(state);
|
|
324
|
+
stateRef.current = state;
|
|
325
|
+
|
|
326
|
+
const isTaskActive = (s = stateRef.current) =>
|
|
327
|
+
s === 'streaming' || s === 'thinking' || s === 'tool_execution';
|
|
328
|
+
|
|
329
|
+
const clearPendingToolEndTimers = () => {
|
|
330
|
+
for (const timerId of pendingToolEndTimersRef.current) {
|
|
331
|
+
clearTimeout(timerId);
|
|
332
|
+
}
|
|
333
|
+
pendingToolEndTimersRef.current.clear();
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const closePendingToolEvents = ({ success = true, output = '' } = {}) => {
|
|
337
|
+
const events = eventsRef.current;
|
|
338
|
+
const additions = [];
|
|
339
|
+
for (const ev of events) {
|
|
340
|
+
if (ev.type !== 'tool_start') continue;
|
|
341
|
+
if (events.some((e) => e.type === 'tool_end' && e.id === ev.id)) continue;
|
|
342
|
+
additions.push({
|
|
343
|
+
type: 'tool_end',
|
|
344
|
+
id: ev.id,
|
|
345
|
+
name: ev.name,
|
|
346
|
+
toolCall: ev.toolCall,
|
|
347
|
+
success,
|
|
348
|
+
output,
|
|
349
|
+
results: null,
|
|
350
|
+
outputIndex: ++outputIndexCounterRef.current,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
if (additions.length === 0) return;
|
|
354
|
+
eventsRef.current = [...events, ...additions];
|
|
355
|
+
flushEventsToState(true);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const finishTaskUi = ({ success = true, output = '' } = {}) => {
|
|
359
|
+
clearPendingToolEndTimers();
|
|
360
|
+
closePendingToolEvents({ success, output });
|
|
361
|
+
exchangeStartRef.current = 0;
|
|
362
|
+
};
|
|
363
|
+
// Ink <Static> rend TOUJOURS au-dessus du contenu dynamique (y compris le header).
|
|
364
|
+
// On n'utilise donc pas Static dans l'arbre UI : header + input restent ancrés.
|
|
365
|
+
|
|
366
|
+
const Input = TextInput.default || TextInput;
|
|
367
|
+
|
|
368
|
+
const stateForRenderThrottleRef = useRef(state);
|
|
369
|
+
stateForRenderThrottleRef.current = state;
|
|
370
|
+
const getRenderThrottle = () => {
|
|
371
|
+
// Pendant le streaming/thinking, on garde un throttle COURT (300ms) pour
|
|
372
|
+
// que l'utilisateur voie le contenu défiler régulièrement. Un throttle trop
|
|
373
|
+
// long (1500ms) causait un "saut" visible entre les frames : à chaque
|
|
374
|
+
// tick, le buffer complet changeait de plusieurs lignes d'un coup, donnant
|
|
375
|
+
// l'impression que le header "descend" et que les textes se décalent.
|
|
376
|
+
// Lissage Ink (incrementalRendering + maxFps=10) gère la fluidité.
|
|
377
|
+
const s = stateForRenderThrottleRef.current;
|
|
378
|
+
if (s === 'streaming' || s === 'thinking') {
|
|
379
|
+
return Math.max(UI_RENDER_THROTTLE_MS, 120);
|
|
380
|
+
}
|
|
381
|
+
if (s === 'tool_execution') {
|
|
382
|
+
return Math.max(UI_RENDER_THROTTLE_MS, 150);
|
|
383
|
+
}
|
|
384
|
+
return UI_RENDER_THROTTLE_MS;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const scrollToBottomIfFollowing = () => {
|
|
388
|
+
if (!followLiveRef.current) return;
|
|
389
|
+
setTimeout(() => {
|
|
390
|
+
transcriptScrollRef.current?.scrollToBottom();
|
|
391
|
+
transcriptScrollRef.current?.remeasure();
|
|
392
|
+
}, 0);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const updateUserActivity = () => {
|
|
396
|
+
lastUserActivityRef.current = Date.now();
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const syncFollowLive = () => {
|
|
400
|
+
const atBottom = transcriptScrollRef.current?.isAtBottom() ?? true;
|
|
401
|
+
followLiveRef.current = atBottom;
|
|
402
|
+
setFollowLive(atBottom);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const flushEventsToState = (immediate = false) => {
|
|
406
|
+
const apply = () => {
|
|
407
|
+
uiFlushTimerRef.current = null;
|
|
408
|
+
setCurrentEvents([...eventsRef.current]);
|
|
409
|
+
scrollToBottomIfFollowing();
|
|
410
|
+
};
|
|
411
|
+
if (immediate) {
|
|
412
|
+
if (uiFlushTimerRef.current) {
|
|
413
|
+
clearTimeout(uiFlushTimerRef.current);
|
|
414
|
+
uiFlushTimerRef.current = null;
|
|
415
|
+
}
|
|
416
|
+
// Utiliser setTimeout pour laisser Ink respirer entre les setState chainés,
|
|
417
|
+
// et pour passer par la phase Timers du event loop (au lieu de setImmediate / Check phase)
|
|
418
|
+
// ce qui donne un rendu plus fluide pendant le streaming
|
|
419
|
+
setTimeout(apply, 0);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (uiFlushTimerRef.current) return;
|
|
423
|
+
uiFlushTimerRef.current = setTimeout(apply, getRenderThrottle());
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const addEvent = (event, immediate = false) => {
|
|
427
|
+
let newEvents = [...eventsRef.current, event];
|
|
428
|
+
if (newEvents.length > MAX_VISIBLE_EVENTS) {
|
|
429
|
+
const removed = newEvents.length - MAX_VISIBLE_EVENTS + 1;
|
|
430
|
+
newEvents = [
|
|
431
|
+
{ type: 'text', content: `\u22EF ${removed} earlier events hidden \u22EF` },
|
|
432
|
+
...newEvents.slice(-MAX_VISIBLE_EVENTS + 1)
|
|
433
|
+
];
|
|
434
|
+
}
|
|
435
|
+
eventsRef.current = newEvents;
|
|
436
|
+
flushEventsToState(immediate);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const findLastEventIndex = (predicate) => {
|
|
440
|
+
for (let i = eventsRef.current.length - 1; i >= 0; i--) {
|
|
441
|
+
if (predicate(eventsRef.current[i], i)) return i;
|
|
442
|
+
}
|
|
443
|
+
return -1;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const updateSubagentEvent = (id, updater, immediate = false) => {
|
|
447
|
+
if (!id) return;
|
|
448
|
+
const idx = findLastEventIndex((e) => e.type === 'subagent' && e.id === id);
|
|
449
|
+
if (idx < 0) return;
|
|
450
|
+
const arr = [...eventsRef.current];
|
|
451
|
+
arr[idx] = updater({ ...arr[idx] });
|
|
452
|
+
eventsRef.current = arr;
|
|
453
|
+
flushEventsToState(immediate);
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const updateLastEvent = (updater) => {
|
|
457
|
+
const arr = eventsRef.current;
|
|
458
|
+
if (arr.length === 0) return;
|
|
459
|
+
const last = { ...arr[arr.length - 1] };
|
|
460
|
+
arr[arr.length - 1] = updater(last);
|
|
461
|
+
eventsRef.current = [...arr];
|
|
462
|
+
flushEventsToState(false);
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// [FIX-modal-overlap] Split modal classification into two categories so we can
|
|
466
|
+
// mount them in different slots of the flex column (instead of the previous
|
|
467
|
+
// `position: 'absolute', bottom: 0` overlays that left the transcript
|
|
468
|
+
// visible underneath).
|
|
469
|
+
//
|
|
470
|
+
// - Agent blocking modals require an answer before the agent can continue.
|
|
471
|
+
// They occupy the MIDDLE of the column and HIDE the transcript.
|
|
472
|
+
// - User pickers (slash menu, mode picker, provider menu, save picker,
|
|
473
|
+
// history picker) are exploratory — the user is just navigating menus.
|
|
474
|
+
// They live in the FOOTER and the transcript SHRINKS to fit above them.
|
|
475
|
+
const isAgentBlockingModal = Boolean(
|
|
476
|
+
askUserDetails ||
|
|
477
|
+
planDetails ||
|
|
478
|
+
state === 'confirmation'
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const isPickerModal = Boolean(
|
|
482
|
+
showSlashMenu ||
|
|
483
|
+
showModePicker ||
|
|
484
|
+
showProviderMenu ||
|
|
485
|
+
showSavePicker ||
|
|
486
|
+
showHistoryPicker
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const isModalOpen = isAgentBlockingModal || isPickerModal;
|
|
490
|
+
|
|
491
|
+
const isScrollActive = hasStarted && !isModalOpen;
|
|
492
|
+
|
|
493
|
+
useInput((input, key) => {
|
|
494
|
+
const sv = transcriptScrollRef.current;
|
|
495
|
+
if (!sv) return;
|
|
496
|
+
|
|
497
|
+
if (key.upArrow) {
|
|
498
|
+
sv.scrollBy(-1);
|
|
499
|
+
syncFollowLive();
|
|
500
|
+
updateUserActivity();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (key.downArrow) {
|
|
504
|
+
sv.scrollBy(1);
|
|
505
|
+
syncFollowLive();
|
|
506
|
+
updateUserActivity();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (key.pageUp) {
|
|
510
|
+
const vh = sv.getViewportHeight();
|
|
511
|
+
sv.scrollBy(-vh);
|
|
512
|
+
syncFollowLive();
|
|
513
|
+
updateUserActivity();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (key.pageDown) {
|
|
517
|
+
const vh = sv.getViewportHeight();
|
|
518
|
+
sv.scrollBy(vh);
|
|
519
|
+
syncFollowLive();
|
|
520
|
+
updateUserActivity();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (key.end) {
|
|
524
|
+
sv.scrollToBottom();
|
|
525
|
+
followLiveRef.current = true;
|
|
526
|
+
setFollowLive(true);
|
|
527
|
+
updateUserActivity();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
}, { isActive: isScrollActive });
|
|
531
|
+
|
|
532
|
+
useInput((input, key) => {
|
|
533
|
+
// [FIX-collapsible-thought] Ctrl+O : délie/replie le DERNIER thought.
|
|
534
|
+
if (key.ctrl && input === 'o') {
|
|
535
|
+
const source = currentEvents;
|
|
536
|
+
let lastThoughtId = null;
|
|
537
|
+
for (let i = source.length - 1; i >= 0; i--) {
|
|
538
|
+
if (source[i].type === 'thought' && source[i].id != null) {
|
|
539
|
+
lastThoughtId = source[i].id;
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (lastThoughtId != null) {
|
|
544
|
+
setExpandedThoughtIds(prev => {
|
|
545
|
+
const next = new Set(prev);
|
|
546
|
+
if (next.has(lastThoughtId)) next.delete(lastThoughtId);
|
|
547
|
+
else next.add(lastThoughtId);
|
|
548
|
+
return next;
|
|
549
|
+
});
|
|
550
|
+
setTimeout(() => transcriptScrollRef.current?.remeasure(), 0);
|
|
551
|
+
}
|
|
552
|
+
updateUserActivity();
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Ctrl+A : toggle expand subagent live view
|
|
557
|
+
if (key.ctrl && input === 'a') {
|
|
558
|
+
const source = currentEvents;
|
|
559
|
+
let lastSubagentId = null;
|
|
560
|
+
for (let i = source.length - 1; i >= 0; i--) {
|
|
561
|
+
if (source[i].type === 'subagent') {
|
|
562
|
+
lastSubagentId = source[i].id;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (lastSubagentId) {
|
|
567
|
+
setExpandedSubagentId(prev => prev === lastSubagentId ? null : lastSubagentId);
|
|
568
|
+
}
|
|
569
|
+
updateUserActivity();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Tab : toggle between PLAN and EXEC execution modes
|
|
574
|
+
if (key.tab) {
|
|
575
|
+
if (isModalOpen) return;
|
|
576
|
+
const newMode = executionMode === 'PLAN' ? 'EXEC' : 'PLAN';
|
|
577
|
+
setExecutionMode(newMode);
|
|
578
|
+
if (agentLoopRef.current) agentLoopRef.current.executionMode = newMode;
|
|
579
|
+
new Conf({ projectName: 'osai-agent' }).set('executionMode', newMode);
|
|
580
|
+
updateUserActivity();
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (!key.escape) return;
|
|
585
|
+
if (isModalOpen) return;
|
|
586
|
+
|
|
587
|
+
if (!agentLoopActiveRef.current) return;
|
|
588
|
+
if (cancelRequestedRef.current) return;
|
|
589
|
+
|
|
590
|
+
try { agentLoopRef.current?.cancel?.(); } catch {}
|
|
591
|
+
updateUserActivity();
|
|
592
|
+
|
|
593
|
+
// Close any active tool_start events with synthetic tool_end
|
|
594
|
+
const events = eventsRef.current;
|
|
595
|
+
const updated = events.map(ev => {
|
|
596
|
+
if (ev.type === 'tool_start' && !events.some(e => e.type === 'tool_end' && e.id === ev.id)) {
|
|
597
|
+
return {
|
|
598
|
+
type: 'tool_end',
|
|
599
|
+
id: ev.id,
|
|
600
|
+
name: ev.name,
|
|
601
|
+
toolCall: ev.toolCall,
|
|
602
|
+
success: false,
|
|
603
|
+
output: 'Cancelled',
|
|
604
|
+
results: null,
|
|
605
|
+
outputIndex: 0
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
return ev;
|
|
609
|
+
});
|
|
610
|
+
eventsRef.current = updated;
|
|
611
|
+
flushEventsToState(true);
|
|
612
|
+
|
|
613
|
+
addEvent({ type: 'text', content: '\nCancellation requested (Esc).\n' });
|
|
614
|
+
cancelRequestedRef.current = true;
|
|
615
|
+
clearPendingToolEndTimers();
|
|
616
|
+
setState('idle');
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
useEffect(() => {
|
|
620
|
+
mockReadlineRef.current.on('question', (promptText) => {
|
|
621
|
+
if (isModalActiveRef.current) return;
|
|
622
|
+
setConfirmationPrompt(promptText);
|
|
623
|
+
setState('confirmation');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
const rl = mockReadlineRef.current;
|
|
627
|
+
const origQuestion = rl.question.bind(rl);
|
|
628
|
+
rl.question = (query, callback) => {
|
|
629
|
+
origQuestion(query, callback);
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
logger.setSilent(true);
|
|
633
|
+
// Debounce des logs notifications pour ne pas interrompre l'input pendant le streaming
|
|
634
|
+
let pendingLog = null;
|
|
635
|
+
let logDebounceTimer = null;
|
|
636
|
+
const flushLog = () => {
|
|
637
|
+
const entry = pendingLog;
|
|
638
|
+
pendingLog = null;
|
|
639
|
+
logDebounceTimer = null;
|
|
640
|
+
if (!entry) return;
|
|
641
|
+
setLogNotifications(prev => {
|
|
642
|
+
const id = Date.now() + Math.random();
|
|
643
|
+
const next = [...prev.slice(-2), { id, msg: entry.msg, level: entry.level }];
|
|
644
|
+
setTimeout(() => {
|
|
645
|
+
setLogNotifications(p => p.filter(n => n.id !== id));
|
|
646
|
+
}, 5000);
|
|
647
|
+
return next;
|
|
648
|
+
});
|
|
649
|
+
};
|
|
650
|
+
const unsubLogs = subscribeToLogs((entry) => {
|
|
651
|
+
pendingLog = entry;
|
|
652
|
+
if (!logDebounceTimer) {
|
|
653
|
+
logDebounceTimer = setTimeout(flushLog, 400);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
agentLoopRef.current = createAgentLoop({
|
|
658
|
+
...agentConfig,
|
|
659
|
+
executionMode,
|
|
660
|
+
readline: mockReadlineRef.current,
|
|
661
|
+
onThought: (text) => {
|
|
662
|
+
const cleaned = text.replace(/[\p{Extended_Pictographic}]/gu, '');
|
|
663
|
+
if (!cleaned) return;
|
|
664
|
+
const arr = eventsRef.current;
|
|
665
|
+
if (arr.length > 0 && arr[arr.length - 1].type === 'thought') {
|
|
666
|
+
// Append : on préserve l'id existant (créé à la 1re occurrence) pour
|
|
667
|
+
// que la mémoire plié/déplié survive aux updates de contenu.
|
|
668
|
+
const prevId = arr[arr.length - 1].id;
|
|
669
|
+
updateLastEvent(ev => ({ ...ev, id: prevId, content: ev.content + cleaned }));
|
|
670
|
+
} else {
|
|
671
|
+
const newId = ++thoughtUiIdCounterRef.current;
|
|
672
|
+
addEvent({ type: 'thought', id: newId, content: cleaned });
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
onMarkdown: (text) => {
|
|
676
|
+
if (!text) return;
|
|
677
|
+
// Safety net: extract any <think> blocks that slipped through
|
|
678
|
+
// (e.g. from session restore or non-streamed paths)
|
|
679
|
+
// and route them to the thought handler.
|
|
680
|
+
let displayText = text;
|
|
681
|
+
const thinkBlockPattern = /<think\b[^>]*>([\s\S]*?)<\/think\s*>|<think\b[^>]*\/>/gi;
|
|
682
|
+
let thinkMatch;
|
|
683
|
+
while ((thinkMatch = thinkBlockPattern.exec(text)) !== null) {
|
|
684
|
+
const content = thinkMatch[1];
|
|
685
|
+
if (content && content.trim()) {
|
|
686
|
+
const cleaned = content.replace(/[\p{Extended_Pictographic}]/gu, '');
|
|
687
|
+
if (cleaned.trim()) {
|
|
688
|
+
const arr = eventsRef.current;
|
|
689
|
+
if (arr.length > 0 && arr[arr.length - 1].type === 'thought') {
|
|
690
|
+
const prevId = arr[arr.length - 1].id;
|
|
691
|
+
updateLastEvent(ev => ({ ...ev, id: prevId, content: ev.content + cleaned }));
|
|
692
|
+
} else {
|
|
693
|
+
const newId = ++thoughtUiIdCounterRef.current;
|
|
694
|
+
addEvent({ type: 'thought', id: newId, content: cleaned });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
displayText = displayText.replace(thinkMatch[0], '');
|
|
699
|
+
}
|
|
700
|
+
const cleanedText = sanitizeUiText(displayText);
|
|
701
|
+
if (!cleanedText.trim()) return;
|
|
702
|
+
const arr = eventsRef.current;
|
|
703
|
+
if (arr.length > 0 && arr[arr.length - 1].type === 'text') {
|
|
704
|
+
updateLastEvent(ev => ({ ...ev, content: ev.content + cleanedText }));
|
|
705
|
+
} else {
|
|
706
|
+
addEvent({ type: 'text', content: cleanedText });
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
onUpdateLastText: (text) => {
|
|
710
|
+
const arr = eventsRef.current;
|
|
711
|
+
if (arr.length === 0) return;
|
|
712
|
+
if (!text && arr[arr.length - 1].type === 'text') {
|
|
713
|
+
arr.pop();
|
|
714
|
+
eventsRef.current = [...arr];
|
|
715
|
+
flushEventsToState(false);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (arr[arr.length - 1].type === 'text') {
|
|
719
|
+
updateLastEvent(ev => ({ ...ev, content: text }));
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
onToolStart: (toolCall) => {
|
|
723
|
+
if (toolCall.tool === 'ASK_USER') return;
|
|
724
|
+
const uid = ++toolUiIdCounterRef.current;
|
|
725
|
+
toolCall._uiId = uid;
|
|
726
|
+
toolCall._startTime = Date.now();
|
|
727
|
+
if (toolCall._subagent) {
|
|
728
|
+
const sid = toolCall._subagentId;
|
|
729
|
+
const newEvent = { type: 'tool_start', id: uid, name: toolCall.tool, toolCall: { ...toolCall } };
|
|
730
|
+
setSubagentEvents(prev => ({ ...prev, [sid]: [...(prev[sid] || []), newEvent] }));
|
|
731
|
+
if (isTaskActive()) setState('tool_execution');
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
addEvent({ type: 'tool_start', id: uid, name: toolCall.tool, toolCall: { ...toolCall } }, true);
|
|
735
|
+
setState('tool_execution');
|
|
736
|
+
},
|
|
737
|
+
onToolResult: (toolCall, result) => {
|
|
738
|
+
const uid = toolCall && toolCall._uiId;
|
|
739
|
+
if (toolCall?._subagent) {
|
|
740
|
+
const sid = toolCall._subagentId;
|
|
741
|
+
const toolEnd = { type: 'tool_end', id: uid, name: toolCall.tool, toolCall: { ...toolCall }, success: result?.success !== false, output: String(result?.output || result?.error || '') };
|
|
742
|
+
setSubagentEvents(prev => {
|
|
743
|
+
const list = prev[sid] || [];
|
|
744
|
+
const idx = list.findIndex(e => e.type === 'tool_start' && e.id === uid);
|
|
745
|
+
const newList = [...list];
|
|
746
|
+
if (idx >= 0) newList[idx] = toolEnd;
|
|
747
|
+
else newList.push(toolEnd);
|
|
748
|
+
return { ...prev, [sid]: newList };
|
|
749
|
+
});
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (toolCall?.tool === 'TASK' && result?.subagentLaunched) {
|
|
753
|
+
// Mettre à jour l'event subagent dans eventsRef avec les infos finales
|
|
754
|
+
const sid = result.subagentId;
|
|
755
|
+
if (sid) {
|
|
756
|
+
updateSubagentEvent(sid, (ev) => {
|
|
757
|
+
ev.status = result.success ? 'completed' : 'failed';
|
|
758
|
+
ev.iterations = result.iterations;
|
|
759
|
+
ev.toolsUsed = result.toolsUsed ?? ev.toolsUsed;
|
|
760
|
+
ev.estimatedTokens = result.estimatedTokens ?? ev.estimatedTokens;
|
|
761
|
+
ev.completionSignal = result.completionSignal;
|
|
762
|
+
ev.findings = result.findings || null;
|
|
763
|
+
ev.findingsPreview = result.findings ? String(result.findings).slice(0, 200) : ev.findingsPreview;
|
|
764
|
+
ev.error = result.error || null;
|
|
765
|
+
ev.elapsed = Date.now() - (ev.startedAt || Date.now());
|
|
766
|
+
return ev;
|
|
767
|
+
}, true);
|
|
768
|
+
}
|
|
769
|
+
setSubagentState((prev) => ({
|
|
770
|
+
...(prev || {}),
|
|
771
|
+
id: result.subagentId || prev?.id,
|
|
772
|
+
status: result.success ? 'completed' : 'failed',
|
|
773
|
+
endedAt: Date.now(),
|
|
774
|
+
iterations: result.iterations,
|
|
775
|
+
toolsUsed: result.toolsUsed,
|
|
776
|
+
estimatedTokens: result.estimatedTokens,
|
|
777
|
+
completionSignal: result.completionSignal,
|
|
778
|
+
findingsPreview: result.findings ? String(result.findings).slice(0, 200) : prev?.findingsPreview,
|
|
779
|
+
error: result.error || null,
|
|
780
|
+
}));
|
|
781
|
+
return; // Ne pas émettre de tool_end pour TASK subagent
|
|
782
|
+
}
|
|
783
|
+
if (toolCall && toolCall.tool !== 'DIR_CHANGE' && toolCall.tool !== 'ASK_USER') {
|
|
784
|
+
const output = result?.success ? result.output : result?.error;
|
|
785
|
+
const results = Array.isArray(result?.results) ? result.results : null;
|
|
786
|
+
const outputIndex = ++outputIndexCounterRef.current;
|
|
787
|
+
const WRITE_TOOLS = ['WRITE_FILE', 'EDIT_FILE', 'APPEND_FILE', 'DELETE_FILE', 'MOVE_FILE', 'COPY_FILE', 'CREATE_DIR', 'CREATE_SKILL'];
|
|
788
|
+
const READ_TOOLS = ['READ_FILE', 'LIST_DIR', 'TREE_VIEW', 'FILE_INFO'];
|
|
789
|
+
const ANIMATED_TOOLS = [...WRITE_TOOLS, ...READ_TOOLS, 'TASK', 'LOAD_SKILL'];
|
|
790
|
+
const doEmit = () => {
|
|
791
|
+
if (eventsRef.current.some((e) => e.type === 'tool_end' && e.id === uid)) return;
|
|
792
|
+
addEvent({
|
|
793
|
+
type: 'tool_end',
|
|
794
|
+
id: uid,
|
|
795
|
+
name: toolCall.tool,
|
|
796
|
+
toolCall: { ...toolCall },
|
|
797
|
+
success: result?.success !== false,
|
|
798
|
+
output: String(output || ''),
|
|
799
|
+
results,
|
|
800
|
+
outputIndex
|
|
801
|
+
}, true);
|
|
802
|
+
if (isTaskActive()) setState('streaming');
|
|
803
|
+
};
|
|
804
|
+
if (ANIMATED_TOOLS.includes(toolCall.tool)) {
|
|
805
|
+
const elapsed = Date.now() - (toolCall._startTime || 0);
|
|
806
|
+
const minDisplay = toolCall.tool === 'TASK'
|
|
807
|
+
? 500
|
|
808
|
+
: WRITE_TOOLS.includes(toolCall.tool)
|
|
809
|
+
? 500
|
|
810
|
+
: READ_TOOLS.includes(toolCall.tool)
|
|
811
|
+
? 400
|
|
812
|
+
: 300;
|
|
813
|
+
if (elapsed < minDisplay) {
|
|
814
|
+
const timerId = setTimeout(() => {
|
|
815
|
+
pendingToolEndTimersRef.current.delete(timerId);
|
|
816
|
+
doEmit();
|
|
817
|
+
}, minDisplay - elapsed);
|
|
818
|
+
pendingToolEndTimersRef.current.add(timerId);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
doEmit();
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
onObservation: (observation) => {
|
|
826
|
+
if (!observation) return;
|
|
827
|
+
if (typeof observation === 'string') {
|
|
828
|
+
const text = sanitizeUiText(observation);
|
|
829
|
+
if (text.trim()) addEvent({ type: 'text', content: text });
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (observation.type === 'context_summary_start') {
|
|
833
|
+
const pruned = (eventsRef.current || []).filter(
|
|
834
|
+
(ev) => ev.type !== 'context_summary_start' && ev.type !== 'context_summary_end'
|
|
835
|
+
);
|
|
836
|
+
eventsRef.current = pruned;
|
|
837
|
+
flushEventsToState(true);
|
|
838
|
+
const uid = ++contextSummaryUiIdCounterRef.current;
|
|
839
|
+
activeContextSummaryIdRef.current = uid;
|
|
840
|
+
addEvent({
|
|
841
|
+
type: 'context_summary_start',
|
|
842
|
+
id: uid,
|
|
843
|
+
totalMessages: observation.totalMessages,
|
|
844
|
+
summarizeCount: observation.summarizeCount,
|
|
845
|
+
keepRecent: observation.keepRecent,
|
|
846
|
+
});
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (observation.type === 'subagent_start') {
|
|
850
|
+
const nextState = {
|
|
851
|
+
id: observation.id,
|
|
852
|
+
description: observation.description,
|
|
853
|
+
status: 'running',
|
|
854
|
+
startedAt: Date.now(),
|
|
855
|
+
toolsUsed: 0,
|
|
856
|
+
prompt: observation.prompt || '',
|
|
857
|
+
};
|
|
858
|
+
setSubagentState(nextState);
|
|
859
|
+
setSubagentEvents(prev => ({ ...prev, [observation.id]: [] }));
|
|
860
|
+
addEvent({
|
|
861
|
+
type: 'subagent',
|
|
862
|
+
...nextState,
|
|
863
|
+
}, true);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (observation.type === 'subagent_progress') {
|
|
867
|
+
setSubagentState((prev) => prev && prev.id === observation.id
|
|
868
|
+
? { ...prev, toolsUsed: (prev.toolsUsed || 0) + 1, lastTool: observation.tool, lastTarget: observation.target || '' }
|
|
869
|
+
: prev);
|
|
870
|
+
updateSubagentEvent(observation.id, (ev) => {
|
|
871
|
+
ev.toolsUsed = (ev.toolsUsed || 0) + 1;
|
|
872
|
+
ev.lastTool = observation.tool;
|
|
873
|
+
ev.lastTarget = observation.target || '';
|
|
874
|
+
ev.elapsed = Date.now() - (ev.startedAt || Date.now());
|
|
875
|
+
return ev;
|
|
876
|
+
});
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (observation.type === 'subagent_stats') {
|
|
880
|
+
setTokenCount(observation.totalEstimatedTokens ?? observation.estimatedTokens ?? null);
|
|
881
|
+
setSubagentState((prev) => prev && prev.id === observation.id
|
|
882
|
+
? { ...prev, estimatedTokens: observation.estimatedTokens }
|
|
883
|
+
: prev);
|
|
884
|
+
updateSubagentEvent(observation.id, (ev) => {
|
|
885
|
+
ev.estimatedTokens = observation.estimatedTokens;
|
|
886
|
+
return ev;
|
|
887
|
+
});
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (observation.type === 'subagent_end') {
|
|
891
|
+
setSubagentState((prev) => {
|
|
892
|
+
if (!prev || prev.id !== observation.id) return prev;
|
|
893
|
+
return {
|
|
894
|
+
...prev,
|
|
895
|
+
status: observation.success ? 'completed' : 'failed',
|
|
896
|
+
endedAt: Date.now(),
|
|
897
|
+
iterations: observation.iterations,
|
|
898
|
+
toolsUsed: observation.toolsUsed ?? prev.toolsUsed,
|
|
899
|
+
estimatedTokens: observation.estimatedTokens ?? prev.estimatedTokens,
|
|
900
|
+
completionSignal: observation.completionSignal,
|
|
901
|
+
findingsPreview: observation.findingsPreview || prev.findingsPreview,
|
|
902
|
+
findings: observation.findings || prev.findings,
|
|
903
|
+
error: observation.error || null,
|
|
904
|
+
};
|
|
905
|
+
});
|
|
906
|
+
updateSubagentEvent(observation.id, (ev) => {
|
|
907
|
+
ev.status = observation.success ? 'completed' : 'failed';
|
|
908
|
+
ev.iterations = observation.iterations;
|
|
909
|
+
ev.toolsUsed = observation.toolsUsed ?? ev.toolsUsed;
|
|
910
|
+
ev.estimatedTokens = observation.estimatedTokens ?? ev.estimatedTokens;
|
|
911
|
+
ev.completionSignal = observation.completionSignal;
|
|
912
|
+
ev.findingsPreview = observation.findingsPreview || null;
|
|
913
|
+
ev.findings = observation.findings || null;
|
|
914
|
+
ev.error = observation.error || null;
|
|
915
|
+
ev.elapsed = observation.durationMs || (Date.now() - (ev.startedAt || Date.now()));
|
|
916
|
+
return ev;
|
|
917
|
+
}, true);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (observation.type === 'context_summary_end') {
|
|
921
|
+
let uid = activeContextSummaryIdRef.current;
|
|
922
|
+
if (!uid) {
|
|
923
|
+
for (let i = eventsRef.current.length - 1; i >= 0; i--) {
|
|
924
|
+
const ev = eventsRef.current[i];
|
|
925
|
+
if (ev.type === 'context_summary_start') {
|
|
926
|
+
const hasEnd = eventsRef.current.slice(i + 1).some((x) => x.type === 'context_summary_end' && x.id === ev.id);
|
|
927
|
+
if (!hasEnd) {
|
|
928
|
+
uid = ev.id;
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
addEvent({
|
|
935
|
+
type: 'context_summary_end',
|
|
936
|
+
id: uid || 0,
|
|
937
|
+
summarizedMessages: observation.summarizedMessages,
|
|
938
|
+
remainingMessages: observation.remainingMessages,
|
|
939
|
+
});
|
|
940
|
+
activeContextSummaryIdRef.current = null;
|
|
941
|
+
}
|
|
942
|
+
},
|
|
943
|
+
onThinkingStart: () => setState('thinking'),
|
|
944
|
+
onThinkingEnd: () => setState('streaming'),
|
|
945
|
+
onTodos: (todosList) => {
|
|
946
|
+
setTodos(todosList);
|
|
947
|
+
const events = eventsRef.current;
|
|
948
|
+
const lastTodosIdx = events.reduce((found, e, i) => e.type === 'todos' ? i : found, -1);
|
|
949
|
+
const allDone = !todosList || todosList.length === 0 || todosList.every(t => t.status === 'done' || t.status === 'completed');
|
|
950
|
+
if (allDone) {
|
|
951
|
+
if (lastTodosIdx >= 0) {
|
|
952
|
+
const arr = [...events];
|
|
953
|
+
arr.splice(lastTodosIdx, 1);
|
|
954
|
+
eventsRef.current = arr;
|
|
955
|
+
flushEventsToState(false);
|
|
956
|
+
}
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (lastTodosIdx >= 0) {
|
|
960
|
+
const arr = [...events];
|
|
961
|
+
arr[lastTodosIdx] = { type: 'todos', todos: todosList };
|
|
962
|
+
eventsRef.current = arr;
|
|
963
|
+
flushEventsToState(false);
|
|
964
|
+
} else {
|
|
965
|
+
addEvent({ type: 'todos', todos: todosList });
|
|
966
|
+
}
|
|
967
|
+
},
|
|
968
|
+
onComplete: (data) => {
|
|
969
|
+
agentLoopActiveRef.current = false;
|
|
970
|
+
if (cancelRequestedRef.current || data?.status === 'cancelled') {
|
|
971
|
+
cancelRequestedRef.current = false;
|
|
972
|
+
finishTaskUi();
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
playSystemBeepIfInactive(lastUserActivityRef.current);
|
|
976
|
+
cancelRequestedRef.current = false;
|
|
977
|
+
finishTaskUi();
|
|
978
|
+
setState('done');
|
|
979
|
+
const signal = data?.completionSignal || 'DONE';
|
|
980
|
+
const elapsedSec = data?.elapsedMs ? data.elapsedMs / 1000 : null;
|
|
981
|
+
badgeInfoRef.current = { signal, elapsed: elapsedSec };
|
|
982
|
+
addEvent({ type: 'badge', signal, elapsed: elapsedSec, provider: currentProviderRef.current && currentProviderRef.current.type !== 'osai' ? currentProviderRef.current.type : null, model: currentProviderRef.current?.model });
|
|
983
|
+
flushEventsToState(true);
|
|
984
|
+
if (badgeTimerRef.current) clearTimeout(badgeTimerRef.current);
|
|
985
|
+
badgeTimerRef.current = setTimeout(() => {
|
|
986
|
+
badgeInfoRef.current = null;
|
|
987
|
+
badgeTimerRef.current = null;
|
|
988
|
+
}, 5000);
|
|
989
|
+
},
|
|
990
|
+
onError: (err) => {
|
|
991
|
+
if (cancelRequestedRef.current) {
|
|
992
|
+
agentLoopActiveRef.current = false;
|
|
993
|
+
cancelRequestedRef.current = false;
|
|
994
|
+
finishTaskUi();
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
agentLoopActiveRef.current = false;
|
|
998
|
+
cancelRequestedRef.current = false;
|
|
999
|
+
finishTaskUi({ success: false, output: String(err || 'Error') });
|
|
1000
|
+
setState('error');
|
|
1001
|
+
badgeInfoRef.current = { signal: 'ERROR', message: err };
|
|
1002
|
+
addEvent({ type: 'badge', signal: 'ERROR', elapsed: null });
|
|
1003
|
+
flushEventsToState(true);
|
|
1004
|
+
},
|
|
1005
|
+
onBadge: (signal) => {
|
|
1006
|
+
const sig = signal?.signal || signal;
|
|
1007
|
+
if (sig === 'DONE' || sig === 'BLOCKED' || sig === 'INCOMPLETE') {
|
|
1008
|
+
cancelRequestedRef.current = false;
|
|
1009
|
+
}
|
|
1010
|
+
badgeInfoRef.current = { signal: sig };
|
|
1011
|
+
const badgeProvider = signal?.provider || (currentProviderRef.current && currentProviderRef.current.type !== 'osai' ? currentProviderRef.current.type : null);
|
|
1012
|
+
const badgeModel = signal?.model || currentProviderRef.current?.model;
|
|
1013
|
+
addEvent({ type: 'badge', signal: sig, elapsed: null, provider: badgeProvider, model: badgeModel });
|
|
1014
|
+
flushEventsToState(true);
|
|
1015
|
+
},
|
|
1016
|
+
onConfirmPrompt: (details) => setImmediate(() => setConfirmDetails(details)),
|
|
1017
|
+
onAskUserPrompt: (details) => {
|
|
1018
|
+
isModalActiveRef.current = true;
|
|
1019
|
+
setImmediate(() => {
|
|
1020
|
+
setAskUserDetails(details);
|
|
1021
|
+
setState('idle');
|
|
1022
|
+
});
|
|
1023
|
+
},
|
|
1024
|
+
onPlanPrompt: (details) => {
|
|
1025
|
+
isModalActiveRef.current = true;
|
|
1026
|
+
setImmediate(() => {
|
|
1027
|
+
setPlanDetails(details);
|
|
1028
|
+
setState('idle');
|
|
1029
|
+
});
|
|
1030
|
+
},
|
|
1031
|
+
onConnectionChange: (connected) => setIsConnected(connected),
|
|
1032
|
+
onStats: (tokens) => setTokenCount(tokens)
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
if (initialSession && Array.isArray(initialSession.conversationHistory)) {
|
|
1036
|
+
const restoredHistory = initialSession.conversationHistory;
|
|
1037
|
+
agentLoopRef.current?.setConversationHistory(restoredHistory);
|
|
1038
|
+
const restoredExchanges = mapHistoryToExchanges(restoredHistory);
|
|
1039
|
+
if (restoredExchanges.length > 0) {
|
|
1040
|
+
setExchanges(restoredExchanges);
|
|
1041
|
+
setHasStarted(true);
|
|
1042
|
+
outputIndexCounterRef.current = 0;
|
|
1043
|
+
setExpandedOutputIndexes(new Set());
|
|
1044
|
+
addEvent({
|
|
1045
|
+
type: 'text',
|
|
1046
|
+
content: `Session restored: ${initialSession.id || 'unknown'} (${restoredHistory.length} messages).`
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Fetch current provider info on mount (with retries)
|
|
1052
|
+
if (!isLocal && agentConfig.server && agentConfig.token) {
|
|
1053
|
+
(async () => {
|
|
1054
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1055
|
+
try {
|
|
1056
|
+
const res = await fetch(`${agentConfig.server}/api/provider`, {
|
|
1057
|
+
headers: { Authorization: `Bearer ${agentConfig.token}` }
|
|
1058
|
+
});
|
|
1059
|
+
if (res.ok) {
|
|
1060
|
+
const data = await res.json();
|
|
1061
|
+
setCurrentProvider({ type: data.type || 'osai', model: data.model || null });
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
} catch {}
|
|
1065
|
+
if (attempt < 2) await new Promise(r => setTimeout(r, 500));
|
|
1066
|
+
}
|
|
1067
|
+
// Fallback if all retries fail
|
|
1068
|
+
setCurrentProvider(prev => prev || { type: 'osai', model: null });
|
|
1069
|
+
})();
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
return () => {
|
|
1073
|
+
unsubLogs();
|
|
1074
|
+
logger.setSilent(false);
|
|
1075
|
+
if (uiFlushTimerRef.current) {
|
|
1076
|
+
clearTimeout(uiFlushTimerRef.current);
|
|
1077
|
+
uiFlushTimerRef.current = null;
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
}, []);
|
|
1081
|
+
|
|
1082
|
+
useEffect(() => {
|
|
1083
|
+
return setupMouseScroll((delta) => {
|
|
1084
|
+
transcriptScrollRef.current?.scrollBy(delta);
|
|
1085
|
+
syncFollowLive();
|
|
1086
|
+
});
|
|
1087
|
+
}, []);
|
|
1088
|
+
|
|
1089
|
+
useEffect(() => {
|
|
1090
|
+
scrollToBottomIfFollowing();
|
|
1091
|
+
}, [currentEvents, exchanges.length, userMessages.length]);
|
|
1092
|
+
|
|
1093
|
+
useEffect(() => {
|
|
1094
|
+
if (expandedOutputIndexes.size > 0) {
|
|
1095
|
+
setTimeout(() => transcriptScrollRef.current?.remeasure(), 0);
|
|
1096
|
+
}
|
|
1097
|
+
}, [expandedOutputIndexes]);
|
|
1098
|
+
|
|
1099
|
+
const handleUserInput = async (val) => {
|
|
1100
|
+
if (!val.trim()) return;
|
|
1101
|
+
updateUserActivity();
|
|
1102
|
+
const showMatch = val.trim().match(/^shows?\s+(\d+)$/i);
|
|
1103
|
+
if (showMatch) {
|
|
1104
|
+
const index = Number.parseInt(showMatch[1], 10);
|
|
1105
|
+
if (Number.isFinite(index) && index > 0) {
|
|
1106
|
+
setExpandedOutputIndexes(prev => {
|
|
1107
|
+
const next = new Set(prev);
|
|
1108
|
+
next.add(index);
|
|
1109
|
+
return next;
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (val === '/') {
|
|
1116
|
+
setShowSlashMenu(true);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (val.startsWith('/')) {
|
|
1121
|
+
await handleSlashCommand(val);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (state !== 'idle' && state !== 'done' && state !== 'error') return;
|
|
1126
|
+
setHasStarted(true);
|
|
1127
|
+
|
|
1128
|
+
const elapsed = exchangeStartRef.current
|
|
1129
|
+
? ((Date.now() - exchangeStartRef.current) / 1000).toFixed(1)
|
|
1130
|
+
: null;
|
|
1131
|
+
|
|
1132
|
+
if (eventsRef.current.length > 0) {
|
|
1133
|
+
const prevUser = userMessages.length > 0 ? userMessages[userMessages.length - 1] : '';
|
|
1134
|
+
if (prevUser) {
|
|
1135
|
+
setExchanges(prev => [...prev, {
|
|
1136
|
+
user: prevUser,
|
|
1137
|
+
events: [...eventsRef.current],
|
|
1138
|
+
elapsed
|
|
1139
|
+
}]);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
eventsRef.current = [];
|
|
1144
|
+
setCurrentEvents([]);
|
|
1145
|
+
clearPendingToolEndTimers();
|
|
1146
|
+
|
|
1147
|
+
exchangeStartRef.current = Date.now();
|
|
1148
|
+
followLiveRef.current = true;
|
|
1149
|
+
setFollowLive(true);
|
|
1150
|
+
setUserMessages(prev => [...prev, val]);
|
|
1151
|
+
setTodos(null);
|
|
1152
|
+
badgeInfoRef.current = null;
|
|
1153
|
+
setConfirmDetails(null);
|
|
1154
|
+
setAskUserDetails(null);
|
|
1155
|
+
setPlanDetails(null);
|
|
1156
|
+
setState('streaming');
|
|
1157
|
+
setBadgeInfo(null);
|
|
1158
|
+
agentLoopActiveRef.current = true;
|
|
1159
|
+
cancelRequestedRef.current = false;
|
|
1160
|
+
// Yielder un tick pour qu'Ink puisse afficher l'état "streaming" AVANT
|
|
1161
|
+
// que l'agent loop commence (sinon le terminal freeze visuellement)
|
|
1162
|
+
// setTimeout plutôt que setImmediate pour laisser Ink re-render via la phase Timers
|
|
1163
|
+
setTimeout(() => agentLoopRef.current?.run(val), 0);
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
const handleSlashSelect = async (item) => {
|
|
1167
|
+
setShowSlashMenu(false);
|
|
1168
|
+
if (item.action === 'exit') {
|
|
1169
|
+
onExit();
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
await handleSlashAction(item.action, item);
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
const handleSlashCancel = () => {
|
|
1176
|
+
setShowSlashMenu(false);
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
const handleModeSelect = async (mode) => {
|
|
1180
|
+
setShowModePicker(false);
|
|
1181
|
+
const modeLabel = mode.value;
|
|
1182
|
+
|
|
1183
|
+
const modeMessages = {
|
|
1184
|
+
CODING: { text: 'Switched to CODING mode — file operations auto-approved', config: 'CODING' },
|
|
1185
|
+
GENERAL: { text: 'Switched to GENERAL mode', config: 'GENERAL' },
|
|
1186
|
+
NETWORK: { text: 'Switched to NETWORK mode — remote network device management', config: 'NETWORK' },
|
|
1187
|
+
SSH: { text: 'Switched to SSH mode — remote SSH connections', config: 'SSH' },
|
|
1188
|
+
PLAN: { text: 'Switched to PLAN mode — read-only, no modifications allowed', config: null },
|
|
1189
|
+
EXEC: { text: 'Switched to EXEC mode — modifications are now allowed', config: null },
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
if (hiddenModes.includes(mode.value)) {
|
|
1193
|
+
addEvent({ type: 'text', content: `\nCannot switch to ${mode.value} mode while connected to a ${agentConfig.mode} device\n` });
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
if (mode.value === 'SSH') {
|
|
1198
|
+
if (isLocal) {
|
|
1199
|
+
try {
|
|
1200
|
+
const config = new Conf({ projectName: 'osai-agent' });
|
|
1201
|
+
const localDevices = config.get('localDevices', []);
|
|
1202
|
+
if (!localDevices.length) {
|
|
1203
|
+
addEvent({ type: 'text', content: `\nAucun périphérique SSH local configuré. Ajoutez-en un avec: osai-agent devices add --local\n` });
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
const machineId = machineIdSync();
|
|
1207
|
+
const key = deriveKey(machineId);
|
|
1208
|
+
const device = { ...localDevices[0] };
|
|
1209
|
+
try {
|
|
1210
|
+
device.auth_decrypted = JSON.parse(decrypt(device.auth_encrypted, key));
|
|
1211
|
+
} catch {
|
|
1212
|
+
addEvent({ type: 'text', content: `\nImpossible de déchiffrer les identifiants du périphérique. Ré-ajoutez-le avec: osai-agent devices add --local\n` });
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
delete device.auth_encrypted;
|
|
1216
|
+
if (agentLoopRef.current) agentLoopRef.current.device = device;
|
|
1217
|
+
// Fall through to apply mode change below
|
|
1218
|
+
addEvent({ type: 'text', content: `\nPériphérique SSH connecté: ${device.name} (${device.ip}:${device.port || 22})\n` });
|
|
1219
|
+
} catch (err) {
|
|
1220
|
+
addEvent({ type: 'text', content: `\nErreur lors du chargement des périphériques locaux: ${err.message}\n` });
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
} else {
|
|
1224
|
+
addEvent({ type: 'text', content: `\nSSH mode requires device setup — use: osai-agent connect\n` });
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// Handle PLAN/EXEC sub-mode toggle
|
|
1230
|
+
if (mode.value === 'PLAN' || mode.value === 'EXEC') {
|
|
1231
|
+
setExecutionMode(mode.value);
|
|
1232
|
+
if (agentLoopRef.current) agentLoopRef.current.executionMode = mode.value;
|
|
1233
|
+
addEvent({ type: 'text', content: `\nSwitched to ${mode.value} mode\n` });
|
|
1234
|
+
new Conf({ projectName: 'osai-agent' }).set('executionMode', mode.value);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
setModeSwitching(modeLabel);
|
|
1239
|
+
await new Promise(resolve => {
|
|
1240
|
+
setTimeout(resolve, BAR_FRAMES.length * 60 + 100);
|
|
1241
|
+
});
|
|
1242
|
+
setModeSwitching(null);
|
|
1243
|
+
|
|
1244
|
+
const info = modeMessages[mode.value];
|
|
1245
|
+
if (info) {
|
|
1246
|
+
addEvent({ type: 'text', content: `\n${info.text}\n` });
|
|
1247
|
+
agentConfig.mode = info.config;
|
|
1248
|
+
if (agentLoopRef.current) agentLoopRef.current.mode = info.config;
|
|
1249
|
+
new Conf({ projectName: 'osai-agent' }).set('mode', info.config);
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
const handleModeCancel = () => {
|
|
1254
|
+
setShowModePicker(false);
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
const handleProviderSelect = async (opt) => {
|
|
1258
|
+
setShowProviderMenu(false);
|
|
1259
|
+
const { action, provider, apiKey } = opt;
|
|
1260
|
+
|
|
1261
|
+
switch (action) {
|
|
1262
|
+
case 'provider_set': {
|
|
1263
|
+
// New flow from redesigned ProviderMenu
|
|
1264
|
+
addEvent({ type: 'text', content: opt.content });
|
|
1265
|
+
if (opt.providerType) {
|
|
1266
|
+
setCurrentProvider({ type: opt.providerType, model: opt.providerModel || null });
|
|
1267
|
+
}
|
|
1268
|
+
// Refresh provider from server to confirm
|
|
1269
|
+
try {
|
|
1270
|
+
const res = await fetch(`${agentConfig.server}/api/provider`, {
|
|
1271
|
+
headers: { Authorization: `Bearer ${agentConfig.token}` }
|
|
1272
|
+
});
|
|
1273
|
+
if (res.ok) {
|
|
1274
|
+
const data = await res.json();
|
|
1275
|
+
setCurrentProvider({ type: data.type || 'osai', model: data.model || null });
|
|
1276
|
+
}
|
|
1277
|
+
} catch {}
|
|
1278
|
+
break;
|
|
1279
|
+
}
|
|
1280
|
+
case 'show_result': {
|
|
1281
|
+
addEvent({ type: 'text', content: opt.content });
|
|
1282
|
+
break;
|
|
1283
|
+
}
|
|
1284
|
+
case 'model_updated': {
|
|
1285
|
+
addEvent({ type: 'text', content: opt.content });
|
|
1286
|
+
if (opt.providerType) {
|
|
1287
|
+
setCurrentProvider(prev => ({ ...prev, type: opt.providerType, model: opt.providerModel || null }));
|
|
1288
|
+
}
|
|
1289
|
+
break;
|
|
1290
|
+
}
|
|
1291
|
+
case 'key_updated': {
|
|
1292
|
+
addEvent({ type: 'text', content: opt.content });
|
|
1293
|
+
if (opt.providerType) {
|
|
1294
|
+
setCurrentProvider(prev => ({ ...prev, type: opt.providerType }));
|
|
1295
|
+
}
|
|
1296
|
+
break;
|
|
1297
|
+
}
|
|
1298
|
+
case 'show': {
|
|
1299
|
+
try {
|
|
1300
|
+
const res = await fetch(`${agentConfig.server}/api/provider`, {
|
|
1301
|
+
headers: { Authorization: `Bearer ${agentConfig.token}` }
|
|
1302
|
+
});
|
|
1303
|
+
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
|
1304
|
+
const data = await res.json();
|
|
1305
|
+
let content;
|
|
1306
|
+
if (data.type === 'osai') {
|
|
1307
|
+
content = '**Current provider:** OS AI Agent\nModel: auto\nQuota: 50 commands/day\n\n_Switch to your own provider with /provider to remove quota limits._';
|
|
1308
|
+
} else {
|
|
1309
|
+
content = `**Current provider:** ${data.type}\nModel: ${data.model || 'N/A'}\nKey: ${data.key || 'N/A'}\nBase URL: ${data.base_url || 'N/A'}\n\n_Using your own API key — no quota limits apply._`;
|
|
1310
|
+
}
|
|
1311
|
+
addEvent({ type: 'text', content });
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
addEvent({ type: 'text', content: `Error: ${err.message}` });
|
|
1314
|
+
}
|
|
1315
|
+
break;
|
|
1316
|
+
}
|
|
1317
|
+
case 'reset': {
|
|
1318
|
+
try {
|
|
1319
|
+
const res = await fetch(`${agentConfig.server}/api/provider`, {
|
|
1320
|
+
method: 'DELETE',
|
|
1321
|
+
headers: { Authorization: `Bearer ${agentConfig.token}` }
|
|
1322
|
+
});
|
|
1323
|
+
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
|
1324
|
+
const data = await res.json();
|
|
1325
|
+
addEvent({ type: 'text', content: `**${data.message || 'Provider reset to OS AI Agent'}**` });
|
|
1326
|
+
setCurrentProvider({ type: 'osai', model: null });
|
|
1327
|
+
} catch (err) {
|
|
1328
|
+
addEvent({ type: 'text', content: `Error: ${err.message}` });
|
|
1329
|
+
}
|
|
1330
|
+
break;
|
|
1331
|
+
}
|
|
1332
|
+
case 'show_local': {
|
|
1333
|
+
import('../../llm/direct.js').then(mod => {
|
|
1334
|
+
const config = mod.getLocalProviderConfig();
|
|
1335
|
+
if (!config) {
|
|
1336
|
+
addEvent({ type: 'text', content: '**Local provider:** Not configured\nNo local provider configured.\n\nUse the menu above or run `osai-agent provider set <type> --key <key> --local`.' });
|
|
1337
|
+
} else {
|
|
1338
|
+
const maskedKey = config.apiKey
|
|
1339
|
+
? config.apiKey.length <= 4 ? config.apiKey : config.apiKey.slice(0, 3) + '...' + config.apiKey.slice(-4)
|
|
1340
|
+
: 'N/A';
|
|
1341
|
+
addEvent({ type: 'text', content: `**Local provider:** ${config.type}\nModel: ${config.model || 'N/A'}\nKey: ${maskedKey}\nBase URL: ${config.baseUrl || 'N/A'}\n\n_Using your own API key locally — no quota limits._` });
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
break;
|
|
1345
|
+
}
|
|
1346
|
+
case 'reset_local': {
|
|
1347
|
+
const { removeLocalProvider } = await import('../commands/provider.js');
|
|
1348
|
+
removeLocalProvider();
|
|
1349
|
+
setCurrentProvider(null);
|
|
1350
|
+
addEvent({ type: 'text', content: '**Local provider reset**' });
|
|
1351
|
+
break;
|
|
1352
|
+
}
|
|
1353
|
+
case 'set_local': {
|
|
1354
|
+
if (!opt.providerType) break;
|
|
1355
|
+
// Mettre à jour le state UI IMMÉDIATEMENT avant l'await,
|
|
1356
|
+
// pour éviter que le header affiche "none" entre-temps
|
|
1357
|
+
setCurrentProvider({ type: opt.providerType, model: opt.model || null });
|
|
1358
|
+
const { saveLocalProvider } = await import('../commands/provider.js');
|
|
1359
|
+
try {
|
|
1360
|
+
saveLocalProvider(opt.providerType, opt.apiKey || null, opt.model || null, opt.baseUrl || null);
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
addEvent({ type: 'text', content: `**Warning: failed to persist provider config** — ${err.message}` });
|
|
1363
|
+
}
|
|
1364
|
+
const masked = opt.apiKey ? opt.apiKey.slice(0, 3) + '...' + opt.apiKey.slice(-4) : 'no key';
|
|
1365
|
+
addEvent({ type: 'text', content: `**Local provider set to ${opt.providerType}** | Model: ${opt.model || 'auto'} | Key: ${masked}` });
|
|
1366
|
+
break;
|
|
1367
|
+
}
|
|
1368
|
+
case 'set': {
|
|
1369
|
+
if (!provider) {
|
|
1370
|
+
addEvent({ type: 'text', content: 'No provider specified.' });
|
|
1371
|
+
break;
|
|
1372
|
+
}
|
|
1373
|
+
try {
|
|
1374
|
+
const body = { type: provider };
|
|
1375
|
+
if (apiKey) body.api_key = apiKey;
|
|
1376
|
+
const res = await fetch(`${agentConfig.server}/api/provider`, {
|
|
1377
|
+
method: 'PUT',
|
|
1378
|
+
headers: {
|
|
1379
|
+
Authorization: `Bearer ${agentConfig.token}`,
|
|
1380
|
+
'Content-Type': 'application/json'
|
|
1381
|
+
},
|
|
1382
|
+
body: JSON.stringify(body)
|
|
1383
|
+
});
|
|
1384
|
+
const data = await res.json();
|
|
1385
|
+
if (!res.ok) {
|
|
1386
|
+
addEvent({ type: 'text', content: `Failed: ${data.error}` });
|
|
1387
|
+
} else {
|
|
1388
|
+
const keyDisplay = data.key ? ` | Key: ${data.key}` : '';
|
|
1389
|
+
const modelDisplay = data.model ? ` | Model: ${data.model}` : '';
|
|
1390
|
+
addEvent({ type: 'text', content: `**Provider set to ${data.type}**${modelDisplay}${keyDisplay}` });
|
|
1391
|
+
setCurrentProvider({ type: data.type, model: data.model || null });
|
|
1392
|
+
}
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
addEvent({ type: 'text', content: `Error: ${err.message}` });
|
|
1395
|
+
}
|
|
1396
|
+
break;
|
|
1397
|
+
}
|
|
1398
|
+
case 'models': {
|
|
1399
|
+
if (!provider) {
|
|
1400
|
+
addEvent({ type: 'text', content: 'No provider specified.' });
|
|
1401
|
+
break;
|
|
1402
|
+
}
|
|
1403
|
+
try {
|
|
1404
|
+
const res = await fetch(`${agentConfig.server}/api/provider/models/${provider}`, {
|
|
1405
|
+
headers: { Authorization: `Bearer ${agentConfig.token}` },
|
|
1406
|
+
});
|
|
1407
|
+
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
|
1408
|
+
const data = await res.json();
|
|
1409
|
+
let content = `**${data.provider} — Available Models**\nSource: ${data.source}`;
|
|
1410
|
+
if (data.warning) content += `\n_${data.warning}_`;
|
|
1411
|
+
content += '\n';
|
|
1412
|
+
for (const m of data.models) {
|
|
1413
|
+
const name = typeof m === 'string' ? m : (m.id || m.name);
|
|
1414
|
+
content += `\n- ${name}`;
|
|
1415
|
+
}
|
|
1416
|
+
addEvent({ type: 'text', content });
|
|
1417
|
+
} catch (err) {
|
|
1418
|
+
addEvent({ type: 'text', content: `Error: ${err.message}` });
|
|
1419
|
+
}
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
const handleProviderCancel = () => {
|
|
1426
|
+
setShowProviderMenu(false);
|
|
1427
|
+
};
|
|
1428
|
+
|
|
1429
|
+
const handleSlashCommand = async (cmd) => {
|
|
1430
|
+
try {
|
|
1431
|
+
const result = await executeSlashCommand(cmd, mockReadlineRef.current, agentConfig.device);
|
|
1432
|
+
if (result && result.action && result.action !== 'none') {
|
|
1433
|
+
await handleSlashAction(result.action, result);
|
|
1434
|
+
}
|
|
1435
|
+
} catch (err) {
|
|
1436
|
+
console.error('Error executing slash command:', err);
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
const handleSlashAction = async (action, data) => {
|
|
1441
|
+
switch (action) {
|
|
1442
|
+
case 'clear':
|
|
1443
|
+
setExchanges([]);
|
|
1444
|
+
setUserMessages([]);
|
|
1445
|
+
eventsRef.current = [];
|
|
1446
|
+
setCurrentEvents([]);
|
|
1447
|
+
outputIndexCounterRef.current = 0;
|
|
1448
|
+
setExpandedOutputIndexes(new Set());
|
|
1449
|
+
setTodos(null);
|
|
1450
|
+
badgeInfoRef.current = null;
|
|
1451
|
+
activeContextSummaryIdRef.current = null;
|
|
1452
|
+
agentLoopRef.current?.setConversationHistory([]);
|
|
1453
|
+
break;
|
|
1454
|
+
case 'exit':
|
|
1455
|
+
onExit();
|
|
1456
|
+
break;
|
|
1457
|
+
case 'logout':
|
|
1458
|
+
{
|
|
1459
|
+
const conf = new Conf({ projectName: 'osai-agent' });
|
|
1460
|
+
conf.delete('token');
|
|
1461
|
+
conf.delete('userId');
|
|
1462
|
+
conf.delete('plan');
|
|
1463
|
+
setLoggingOut(true);
|
|
1464
|
+
setTimeout(() => onExit(), 2000);
|
|
1465
|
+
}
|
|
1466
|
+
break;
|
|
1467
|
+
case 'update_mode':
|
|
1468
|
+
if (data && data.mode) {
|
|
1469
|
+
agentConfig.mode = data.mode;
|
|
1470
|
+
if (agentLoopRef.current) agentLoopRef.current.mode = data.mode;
|
|
1471
|
+
}
|
|
1472
|
+
break;
|
|
1473
|
+
case 'provider':
|
|
1474
|
+
setShowProviderMenu(true);
|
|
1475
|
+
break;
|
|
1476
|
+
case 'plan':
|
|
1477
|
+
setExecutionMode('PLAN');
|
|
1478
|
+
if (agentLoopRef.current) agentLoopRef.current.executionMode = 'PLAN';
|
|
1479
|
+
addEvent({ type: 'text', content: '\nSwitched to PLAN mode — read-only, no modifications allowed\n' });
|
|
1480
|
+
new Conf({ projectName: 'osai-agent' }).set('executionMode', 'PLAN');
|
|
1481
|
+
break;
|
|
1482
|
+
case 'exec':
|
|
1483
|
+
setExecutionMode('EXEC');
|
|
1484
|
+
if (agentLoopRef.current) agentLoopRef.current.executionMode = 'EXEC';
|
|
1485
|
+
addEvent({ type: 'text', content: '\nSwitched to EXEC mode — modifications are now allowed\n' });
|
|
1486
|
+
new Conf({ projectName: 'osai-agent' }).set('executionMode', 'EXEC');
|
|
1487
|
+
break;
|
|
1488
|
+
case 'mode':
|
|
1489
|
+
case 'show_mode_picker':
|
|
1490
|
+
setShowModePicker(true);
|
|
1491
|
+
break;
|
|
1492
|
+
case 'help':
|
|
1493
|
+
addEvent({ type: 'text', content: `
|
|
1494
|
+
**Available Commands:**
|
|
1495
|
+
|
|
1496
|
+
/clear - Clear displayed history
|
|
1497
|
+
/help - Show available commands
|
|
1498
|
+
/new - Start a new section (saves current)
|
|
1499
|
+
/status - Show OS, mode and session info
|
|
1500
|
+
/stats - Iterations, tokens and timing
|
|
1501
|
+
/history - Saved sessions - resume a past conversation
|
|
1502
|
+
/save - Save current session
|
|
1503
|
+
/context - Show conversation context size
|
|
1504
|
+
/devices - List configured devices
|
|
1505
|
+
/provider - Manage AI providers and API keys (BYOK = no quota)
|
|
1506
|
+
/mode - Switch GENERAL / CODING / NETWORK / SSH mode
|
|
1507
|
+
/plan - Switch to PLAN mode (read-only, no modifications)
|
|
1508
|
+
/exec - Switch to EXEC mode (modifications allowed)
|
|
1509
|
+
/todos - Show current todo list
|
|
1510
|
+
/skills - List available skills
|
|
1511
|
+
/logout - Log out and clear credentials
|
|
1512
|
+
/exit - Exit the agent
|
|
1513
|
+
|
|
1514
|
+
_Tip: Type "/" to open the command menu with search and arrow-key navigation
|
|
1515
|
+
Tip: Press Tab to toggle between PLAN and EXEC mode_
|
|
1516
|
+
` });
|
|
1517
|
+
break;
|
|
1518
|
+
case 'status':
|
|
1519
|
+
addEvent({ type: 'text', content: `
|
|
1520
|
+
**Status:**
|
|
1521
|
+
- Mode: ${agentConfig.mode}/${executionMode}
|
|
1522
|
+
- Device: ${agentConfig.device}
|
|
1523
|
+
- Directory: ${currentDirectory}
|
|
1524
|
+
- Connected: ${isConnected ? 'Yes' : 'No'}
|
|
1525
|
+
- Provider: ${currentProvider ? (currentProvider.type === 'osai' ? 'OS AI Agent (auto)' : `${currentProvider.type}${currentProvider.model ? '/' + currentProvider.model : ''}`) : 'None'}
|
|
1526
|
+
` });
|
|
1527
|
+
break;
|
|
1528
|
+
case 'stats':
|
|
1529
|
+
if (agentLoopRef.current) {
|
|
1530
|
+
const stats = agentLoopRef.current.getStats();
|
|
1531
|
+
addEvent({ type: 'text', content: `
|
|
1532
|
+
**Statistics:**
|
|
1533
|
+
- Iterations: ${stats.iterations}
|
|
1534
|
+
- Commands Executed: ${stats.commandsExecuted}
|
|
1535
|
+
- Estimated Tokens: ${stats.estimatedTokens}
|
|
1536
|
+
- Elapsed Time: ${formatDuration(stats.elapsedMs / 1000)}
|
|
1537
|
+
- Conversation Length: ${stats.conversationLength}
|
|
1538
|
+
` });
|
|
1539
|
+
}
|
|
1540
|
+
break;
|
|
1541
|
+
case 'todos':
|
|
1542
|
+
if (todos && todos.length > 0) {
|
|
1543
|
+
const todoList = todos.map((t, i) => `${i + 1}. [${t.status}] ${t.text || t.description || ''}`).join('\n');
|
|
1544
|
+
addEvent({ type: 'text', content: `**Current Todos:**\n\n${todoList}` });
|
|
1545
|
+
} else {
|
|
1546
|
+
addEvent({ type: 'text', content: 'No todos currently.' });
|
|
1547
|
+
}
|
|
1548
|
+
break;
|
|
1549
|
+
case 'skills': {
|
|
1550
|
+
try {
|
|
1551
|
+
const { discoverSkills, formatSkillsList } = await import('../skills/loader.js');
|
|
1552
|
+
const skills = await discoverSkills({ refresh: true });
|
|
1553
|
+
addEvent({
|
|
1554
|
+
type: 'text',
|
|
1555
|
+
content: skills.length
|
|
1556
|
+
? `**Available Skills:**\n\n${formatSkillsList(skills)}\n\nLoad with LOAD_SKILL or: osai-agent skills show <name>`
|
|
1557
|
+
: 'No skills found. Create ~/.osai-agent/skills/<name>/SKILL.md or use CREATE_SKILL',
|
|
1558
|
+
});
|
|
1559
|
+
} catch (err) {
|
|
1560
|
+
addEvent({ type: 'text', content: `Failed to load skills: ${err.message}` });
|
|
1561
|
+
}
|
|
1562
|
+
break;
|
|
1563
|
+
}
|
|
1564
|
+
case 'context':
|
|
1565
|
+
if (agentLoopRef.current) {
|
|
1566
|
+
const stats = agentLoopRef.current.getStats();
|
|
1567
|
+
addEvent({ type: 'text', content: `
|
|
1568
|
+
**Context Information:**
|
|
1569
|
+
- Conversation Messages: ${stats.conversationLength}
|
|
1570
|
+
- Estimated Tokens: ${stats.estimatedTokens}
|
|
1571
|
+
` });
|
|
1572
|
+
}
|
|
1573
|
+
break;
|
|
1574
|
+
case 'save': {
|
|
1575
|
+
const hasContent = exchanges.length > 0 || eventsRef.current.length > 0;
|
|
1576
|
+
if (!hasContent) {
|
|
1577
|
+
addEvent({ type: 'text', content: 'Nothing to save — no conversation yet.' });
|
|
1578
|
+
} else {
|
|
1579
|
+
setSavePendingAction('save');
|
|
1580
|
+
setShowSavePicker(true);
|
|
1581
|
+
}
|
|
1582
|
+
break;
|
|
1583
|
+
}
|
|
1584
|
+
case 'new': {
|
|
1585
|
+
const hasContentNew = exchanges.length > 0 || eventsRef.current.length > 0;
|
|
1586
|
+
if (!hasContentNew) {
|
|
1587
|
+
setExchanges([]);
|
|
1588
|
+
setUserMessages([]);
|
|
1589
|
+
eventsRef.current = [];
|
|
1590
|
+
setCurrentEvents([]);
|
|
1591
|
+
outputIndexCounterRef.current = 0;
|
|
1592
|
+
setExpandedOutputIndexes(new Set());
|
|
1593
|
+
setTodos(null);
|
|
1594
|
+
badgeInfoRef.current = null;
|
|
1595
|
+
activeContextSummaryIdRef.current = null;
|
|
1596
|
+
agentLoopRef.current?.setConversationHistory([]);
|
|
1597
|
+
setHasStarted(false);
|
|
1598
|
+
} else {
|
|
1599
|
+
setSavePendingAction('new');
|
|
1600
|
+
setShowSavePicker(true);
|
|
1601
|
+
}
|
|
1602
|
+
break;
|
|
1603
|
+
}
|
|
1604
|
+
case 'history': {
|
|
1605
|
+
try {
|
|
1606
|
+
setLoadingHistory(true);
|
|
1607
|
+
const sm = new SessionManager({ server: agentConfig.server, token: agentConfig.token });
|
|
1608
|
+
const sessions = await sm.listAll();
|
|
1609
|
+
setLoadingHistory(false);
|
|
1610
|
+
if (!sessions.length) {
|
|
1611
|
+
addEvent({ type: 'text', content: 'No saved sessions found.' });
|
|
1612
|
+
} else {
|
|
1613
|
+
setHistorySessions(sessions);
|
|
1614
|
+
setShowHistoryPicker(true);
|
|
1615
|
+
}
|
|
1616
|
+
} catch (err) {
|
|
1617
|
+
setLoadingHistory(false);
|
|
1618
|
+
addEvent({ type: 'text', content: `Error loading sessions: ${err.message}` });
|
|
1619
|
+
}
|
|
1620
|
+
break;
|
|
1621
|
+
}
|
|
1622
|
+
case 'devices': {
|
|
1623
|
+
if (isLocal) {
|
|
1624
|
+
addEvent({ type: 'text', content: '**Local Mode:** Devices can be managed via CLI:\n\n osai-agent devices list\n osai-agent devices add\n osai-agent devices remove <id>' });
|
|
1625
|
+
} else {
|
|
1626
|
+
try {
|
|
1627
|
+
const res = await fetch(`${agentConfig.server}/devices`, {
|
|
1628
|
+
headers: { Authorization: `Bearer ${agentConfig.token}` }
|
|
1629
|
+
});
|
|
1630
|
+
if (!res.ok) throw new Error(`Server ${res.status}: ${await res.text()}`);
|
|
1631
|
+
const devices = await res.json();
|
|
1632
|
+
if (!devices.length) {
|
|
1633
|
+
addEvent({ type: 'text', content: 'No devices configured. Add one with: osai-agent devices add' });
|
|
1634
|
+
} else {
|
|
1635
|
+
const list = devices.map(d =>
|
|
1636
|
+
`- ${d.name} (${d.type}) — ${d.ip}:${d.port}`
|
|
1637
|
+
).join('\n');
|
|
1638
|
+
addEvent({ type: 'text', content: `**Configured devices:**\n\n${list}` });
|
|
1639
|
+
}
|
|
1640
|
+
} catch (err) {
|
|
1641
|
+
addEvent({ type: 'text', content: `Error fetching devices: ${err.message}\nIs the server running?` });
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
break;
|
|
1645
|
+
}
|
|
1646
|
+
default:
|
|
1647
|
+
break;
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
|
|
1651
|
+
const handleSaveSelect = async (option) => {
|
|
1652
|
+
setShowSavePicker(false);
|
|
1653
|
+
const action = savePendingAction;
|
|
1654
|
+
setSavePendingAction(null);
|
|
1655
|
+
|
|
1656
|
+
if (option === 'cancel' || option === 'discard') {
|
|
1657
|
+
if (option === 'discard') {
|
|
1658
|
+
setExchanges([]);
|
|
1659
|
+
setUserMessages([]);
|
|
1660
|
+
eventsRef.current = [];
|
|
1661
|
+
setCurrentEvents([]);
|
|
1662
|
+
outputIndexCounterRef.current = 0;
|
|
1663
|
+
setExpandedOutputIndexes(new Set());
|
|
1664
|
+
setTodos(null);
|
|
1665
|
+
badgeInfoRef.current = null;
|
|
1666
|
+
activeContextSummaryIdRef.current = null;
|
|
1667
|
+
agentLoopRef.current?.setConversationHistory([]);
|
|
1668
|
+
if (action === 'new') setHasStarted(false);
|
|
1669
|
+
}
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
const sm = new SessionManager({ server: agentConfig.server, token: agentConfig.token });
|
|
1674
|
+
const sessionId = sm.generateSessionId();
|
|
1675
|
+
|
|
1676
|
+
const buildConversationHistory = () => {
|
|
1677
|
+
const loopHistory = agentLoopRef.current?.getConversationHistory?.();
|
|
1678
|
+
if (Array.isArray(loopHistory) && loopHistory.length > 0) {
|
|
1679
|
+
return loopHistory
|
|
1680
|
+
.filter((m) => m && typeof m.role === 'string' && typeof m.content === 'string')
|
|
1681
|
+
.filter((m) => (m.role === 'user' || m.role === 'assistant') && m.content.trim().length > 0);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
const history = [];
|
|
1685
|
+
for (const ex of exchanges) {
|
|
1686
|
+
if (ex.user) history.push({ role: 'user', content: ex.user });
|
|
1687
|
+
if (ex.events) {
|
|
1688
|
+
let text = '';
|
|
1689
|
+
for (const ev of ex.events) {
|
|
1690
|
+
if (ev.type === 'text' && typeof ev.content === 'string') text += ev.content;
|
|
1691
|
+
}
|
|
1692
|
+
if (text.trim()) history.push({ role: 'assistant', content: text });
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
const currentUser = userMessages[userMessages.length - 1];
|
|
1696
|
+
if (currentUser) history.push({ role: 'user', content: currentUser });
|
|
1697
|
+
let currentText = '';
|
|
1698
|
+
for (const ev of (eventsRef.current || [])) {
|
|
1699
|
+
if (ev.type === 'text' && typeof ev.content === 'string') currentText += ev.content;
|
|
1700
|
+
}
|
|
1701
|
+
if (currentText.trim()) history.push({ role: 'assistant', content: currentText });
|
|
1702
|
+
return history;
|
|
1703
|
+
};
|
|
1704
|
+
|
|
1705
|
+
const data = {
|
|
1706
|
+
title: userMessages[0]?.slice(0, 60) || 'Untitled',
|
|
1707
|
+
conversationHistory: buildConversationHistory(),
|
|
1708
|
+
mode: agentConfig.mode,
|
|
1709
|
+
os: agentConfig.device,
|
|
1710
|
+
stats: agentLoopRef.current?.getStats() || {},
|
|
1711
|
+
};
|
|
1712
|
+
|
|
1713
|
+
try {
|
|
1714
|
+
if (option === 'cloud') {
|
|
1715
|
+
await sm.saveCloud(sessionId, data);
|
|
1716
|
+
addEvent({ type: 'text', content: `Session saved to cloud.` });
|
|
1717
|
+
} else {
|
|
1718
|
+
await sm.saveLocal(sessionId, data);
|
|
1719
|
+
addEvent({ type: 'text', content: `Session saved locally.` });
|
|
1720
|
+
}
|
|
1721
|
+
} catch (err) {
|
|
1722
|
+
addEvent({ type: 'text', content: `Save failed: ${err.message}` });
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
if (action === 'new') {
|
|
1726
|
+
setExchanges([]);
|
|
1727
|
+
setUserMessages([]);
|
|
1728
|
+
eventsRef.current = [];
|
|
1729
|
+
setCurrentEvents([]);
|
|
1730
|
+
outputIndexCounterRef.current = 0;
|
|
1731
|
+
setExpandedOutputIndexes(new Set());
|
|
1732
|
+
setTodos(null);
|
|
1733
|
+
badgeInfoRef.current = null;
|
|
1734
|
+
activeContextSummaryIdRef.current = null;
|
|
1735
|
+
agentLoopRef.current?.setConversationHistory([]);
|
|
1736
|
+
setHasStarted(false);
|
|
1737
|
+
}
|
|
1738
|
+
};
|
|
1739
|
+
|
|
1740
|
+
const handleSaveCancel = () => {
|
|
1741
|
+
setShowSavePicker(false);
|
|
1742
|
+
setSavePendingAction(null);
|
|
1743
|
+
};
|
|
1744
|
+
|
|
1745
|
+
const handleHistorySelect = async (session) => {
|
|
1746
|
+
setShowHistoryPicker(false);
|
|
1747
|
+
setLoadingHistory(true);
|
|
1748
|
+
try {
|
|
1749
|
+
const sm = new SessionManager({ server: agentConfig.server, token: agentConfig.token });
|
|
1750
|
+
const data = await sm.load(session.id);
|
|
1751
|
+
if (!data?.conversationHistory) {
|
|
1752
|
+
addEvent({ type: 'text', content: 'Failed to load session.' });
|
|
1753
|
+
setLoadingHistory(false);
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
agentLoopRef.current?.setConversationHistory(data.conversationHistory);
|
|
1757
|
+
setExchanges(mapHistoryToExchanges(data.conversationHistory));
|
|
1758
|
+
setUserMessages(data.conversationHistory.filter(m => m.role === 'user').map(m => m.content));
|
|
1759
|
+
setHasStarted(true);
|
|
1760
|
+
} catch (err) {
|
|
1761
|
+
addEvent({ type: 'text', content: `Error loading session: ${err.message}` });
|
|
1762
|
+
} finally {
|
|
1763
|
+
setLoadingHistory(false);
|
|
1764
|
+
}
|
|
1765
|
+
};
|
|
1766
|
+
|
|
1767
|
+
const handleHistoryCancel = () => {
|
|
1768
|
+
setShowHistoryPicker(false);
|
|
1769
|
+
setHistorySessions([]);
|
|
1770
|
+
};
|
|
1771
|
+
|
|
1772
|
+
const handleConfirm = (answer) => {
|
|
1773
|
+
setState('streaming');
|
|
1774
|
+
setConfirmDetails(null);
|
|
1775
|
+
mockReadlineRef.current.answer(answer);
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
const handleAskUserAnswer = (answer) => {
|
|
1779
|
+
isModalActiveRef.current = false;
|
|
1780
|
+
setAskUserDetails(null);
|
|
1781
|
+
mockReadlineRef.current.answer(answer);
|
|
1782
|
+
};
|
|
1783
|
+
|
|
1784
|
+
const handlePlanConfirm = (approved) => {
|
|
1785
|
+
isModalActiveRef.current = false;
|
|
1786
|
+
setPlanDetails(null);
|
|
1787
|
+
mockReadlineRef.current.answer(approved ? 'y' : 'n');
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1790
|
+
const isInputDisabled = () => {
|
|
1791
|
+
return !!(askUserDetails || planDetails || (state !== 'idle' && state !== 'done' && state !== 'error'));
|
|
1792
|
+
};
|
|
1793
|
+
|
|
1794
|
+
const currentUserMessage = userMessages.length > 0 ? userMessages[userMessages.length - 1] : null;
|
|
1795
|
+
const logRows = logNotifications.length > 0 ? 1 : 0;
|
|
1796
|
+
const headerRows = getHeaderRows(columns);
|
|
1797
|
+
const inputFooterRows = !isAgentBlockingModal && !isPickerModal && !modeSwitching ? 5 : 0;
|
|
1798
|
+
const processingRows = (state === 'streaming' || state === 'thinking' || state === 'tool_execution') ? 1 : 0;
|
|
1799
|
+
const loadingRows = loadingHistory ? 1 : 0;
|
|
1800
|
+
const modeSwitchRows = modeSwitching ? 1 : 0;
|
|
1801
|
+
const errorRows = state === 'error' ? 3 : 0;
|
|
1802
|
+
const footerRows = inputFooterRows + processingRows + loadingRows + modeSwitchRows + errorRows;
|
|
1803
|
+
// Pickers are roughly 14 rows tall (header + search + ~10 items + footer).
|
|
1804
|
+
// When a picker is open we must shrink the transcript scroll area by this
|
|
1805
|
+
// amount so the picker fits in the footer without overlapping it.
|
|
1806
|
+
const pickerRows = 14;
|
|
1807
|
+
// When an agent-blocking modal is open the transcript is not mounted at
|
|
1808
|
+
// all, so we don't need a positive middle height — the modal just fills the
|
|
1809
|
+
// remaining flex space.
|
|
1810
|
+
const middleMaxHeight = (() => {
|
|
1811
|
+
if (!rows) return undefined;
|
|
1812
|
+
if (isAgentBlockingModal) return 0;
|
|
1813
|
+
if (isPickerModal) {
|
|
1814
|
+
return Math.max(6, rows - headerRows - logRows - footerRows - pickerRows);
|
|
1815
|
+
}
|
|
1816
|
+
return Math.max(6, rows - headerRows - logRows - footerRows);
|
|
1817
|
+
})();
|
|
1818
|
+
|
|
1819
|
+
return h(Box, { flexDirection: 'column', width: '100%', height: rows || undefined },
|
|
1820
|
+
|
|
1821
|
+
/* ── Welcome screen (before first message) ── */
|
|
1822
|
+
!hasStarted ? h(Box, { flexDirection: 'column', flexGrow: 1 },
|
|
1823
|
+
h(Box, { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', paddingTop: 1 },
|
|
1824
|
+
h(Box, { flexDirection: 'column' },
|
|
1825
|
+
h(Text, { color: 'white' }, '\u250C\u2500\u2500\u2500\u2510'),
|
|
1826
|
+
h(Box, { flexDirection: 'row' },
|
|
1827
|
+
h(Text, { color: 'white' }, '\u2502 '),
|
|
1828
|
+
h(Text, { color: '#4a9eff' }, '\u25B2'),
|
|
1829
|
+
h(Text, { color: 'white' }, ' \u2502'),
|
|
1830
|
+
),
|
|
1831
|
+
h(Text, { color: 'white' }, '\u2514\u2500\u2500\u2500\u2518'),
|
|
1832
|
+
),
|
|
1833
|
+
h(Box, { paddingLeft: 1, flexDirection: 'column' },
|
|
1834
|
+
...LOGO_LINES.map((line, idx) =>
|
|
1835
|
+
h(Text, { key: idx, color: idx < 2 ? '#4a9eff' : '#1a5fad' }, line)
|
|
1836
|
+
),
|
|
1837
|
+
)
|
|
1838
|
+
),
|
|
1839
|
+
h(Box, { paddingTop: 1, flexDirection: 'row', justifyContent: 'center' }, h(Text, { color: '#d0e4ff' }, TAGLINE)),
|
|
1840
|
+
h(Box, { flexDirection: 'row', justifyContent: 'center' }, h(Text, { color: '#3d6080' }, VERSION)),
|
|
1841
|
+
currentEvents.length > 0 ? h(EventList, { events: currentEvents, expandedOutputIndexes, thoughtStreaming: state === 'thinking', expandedThoughtIds, animate: isTaskActive(), expandedSubagentId, allSubagentEvents: subagentEvents }) : null,
|
|
1842
|
+
showSlashMenu ? h(Box, { paddingTop: 1, alignItems: 'center' }, h(SlashMenu, { visible: true, onSelect: handleSlashSelect, onCancel: handleSlashCancel })) : null,
|
|
1843
|
+
showModePicker ? h(Box, { paddingTop: 1, alignItems: 'center' }, h(ModePicker, { visible: true, onSelect: handleModeSelect, onCancel: handleModeCancel, currentMode: agentConfig.mode, currentExecutionMode: executionMode, hiddenModes })) : null,
|
|
1844
|
+
showProviderMenu ? h(Box, { paddingTop: 1, alignItems: 'center' }, h(ProviderMenu, { visible: true, isLocal, onSelect: handleProviderSelect, onCancel: handleProviderCancel, serverUrl: agentConfig.server, token: agentConfig.token, currentProvider })) : null,
|
|
1845
|
+
showSavePicker ? h(Box, { paddingTop: 1, alignItems: 'center' }, h(SavePicker, { visible: true, onSelect: handleSaveSelect, onCancel: handleSaveCancel, hasCloud: !!(agentConfig.server && agentConfig.token && !isLocal) })) : null,
|
|
1846
|
+
showHistoryPicker ? h(Box, { paddingTop: 1, alignItems: 'center' }, h(HistoryPicker, { sessions: historySessions, visible: true, onSelect: handleHistorySelect, onCancel: handleHistoryCancel })) : null,
|
|
1847
|
+
loadingHistory ? h(Box, { paddingLeft: 2, paddingY: 1 }, h(LoadingDots, { text: 'Loading sessions' })) : null,
|
|
1848
|
+
!showSlashMenu && !showModePicker && !showProviderMenu && !modeSwitching && !showSavePicker && !showHistoryPicker ? h(Box, { flexDirection: 'column' },
|
|
1849
|
+
h(Box, { paddingTop: 2, paddingBottom: 1, flexDirection: 'row', justifyContent: 'center' },
|
|
1850
|
+
h(Text, { color: '#565f89', dimColor: true }, 'Type "/" for commands (with search + arrow keys)')
|
|
1851
|
+
),
|
|
1852
|
+
h(InputFrameLine, { columns }),
|
|
1853
|
+
h(Box, { paddingX: 2 },
|
|
1854
|
+
h(InputField, {
|
|
1855
|
+
onSubmit: handleUserInput,
|
|
1856
|
+
placeholder: 'What would you like to do?',
|
|
1857
|
+
disabled: isInputDisabled()
|
|
1858
|
+
})
|
|
1859
|
+
),
|
|
1860
|
+
h(InputFrameLine, { columns }),
|
|
1861
|
+
h(Box, { paddingLeft: 2, paddingTop: 1 },
|
|
1862
|
+
h(Text, { color: '#e0af68', bold: true }, `[${agentConfig.mode}/${executionMode}]`),
|
|
1863
|
+
h(Text, { color: '#565f89', dimColor: true }, ' '),
|
|
1864
|
+
h(Text, { color: 'white' }, '\u2590'),
|
|
1865
|
+
h(Text, { color: '#4a9eff' }, '\u25B3'),
|
|
1866
|
+
h(Text, { color: 'white' }, '\u258C'),
|
|
1867
|
+
isLocal
|
|
1868
|
+
? (currentProvider && currentProvider.type
|
|
1869
|
+
? h(Text, { color: '#9ece6a' }, ` ${currentProvider.type}${currentProvider.model ? '/' + currentProvider.model : ''} - `)
|
|
1870
|
+
: h(Text, { color: '#565f89', dimColor: true }, ' No provider - '))
|
|
1871
|
+
: h(Text, { color: '#565f89', dimColor: true }, ' OS AI AGENT - '),
|
|
1872
|
+
!isLocal && currentProvider && currentProvider.type !== 'osai'
|
|
1873
|
+
? h(Text, { color: '#9ece6a' }, `${currentProvider.type}${currentProvider.model ? '/' + currentProvider.model : ''}`)
|
|
1874
|
+
: null,
|
|
1875
|
+
!isLocal && currentProvider && currentProvider.type !== 'osai' ? h(Text, { color: '#565f89', dimColor: true }, ' | ') : null,
|
|
1876
|
+
h(Text, { color: '#7aa2f7' }, deviceDisplayName || currentDirectory)
|
|
1877
|
+
)
|
|
1878
|
+
) : null
|
|
1879
|
+
) : null,
|
|
1880
|
+
|
|
1881
|
+
/* ── Content area (after start) ── */
|
|
1882
|
+
hasStarted ? h(Box, { flexDirection: 'column', flexGrow: 1, width: '100%', height: rows || undefined },
|
|
1883
|
+
|
|
1884
|
+
/* ── Fixed top: log notifications, header, todos (always above messages) ── */
|
|
1885
|
+
h(Box, { flexShrink: 0, flexDirection: 'column', width: '100%' },
|
|
1886
|
+
logNotifications.length > 0
|
|
1887
|
+
? h(Box, { paddingLeft: 2, paddingBottom: 1 },
|
|
1888
|
+
h(Text, { color: '#565f89', dimColor: true }, logNotifications[logNotifications.length - 1].msg)
|
|
1889
|
+
)
|
|
1890
|
+
: null,
|
|
1891
|
+
h(Header, {
|
|
1892
|
+
key: `header_${columns}`,
|
|
1893
|
+
mode: agentConfig.mode,
|
|
1894
|
+
device: typeof agentConfig.device === 'string' ? agentConfig.device : agentConfig.device?.name || 'SSH',
|
|
1895
|
+
isConnected,
|
|
1896
|
+
isLocal,
|
|
1897
|
+
provider: currentProvider,
|
|
1898
|
+
executionMode,
|
|
1899
|
+
tokenCount,
|
|
1900
|
+
subagentActive: subagentState?.status === 'running',
|
|
1901
|
+
columns,
|
|
1902
|
+
}),
|
|
1903
|
+
),
|
|
1904
|
+
|
|
1905
|
+
/* ── Middle: blocking modal (no transcript) OR transcript ── */
|
|
1906
|
+
// [FIX-modal-overlap] Agent-blocking modals (confirmation, askUserDetails,
|
|
1907
|
+
// planDetails) take over the entire middle of the column. The transcript
|
|
1908
|
+
// is not mounted at all in this branch — the opaque ModalPanel prevents
|
|
1909
|
+
// any leak-through, and the ModalPanel's parent flexGrow:1 keeps it
|
|
1910
|
+
// vertically centered in the available space.
|
|
1911
|
+
isAgentBlockingModal
|
|
1912
|
+
? h(Box, { flexGrow: 1, flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', overflow: 'hidden' },
|
|
1913
|
+
h(ModalPanel, { flexGrow: 1 },
|
|
1914
|
+
state === 'confirmation'
|
|
1915
|
+
? h(ConfirmationDialog, { promptText: confirmationPrompt, details: confirmDetails, onConfirm: handleConfirm })
|
|
1916
|
+
: null,
|
|
1917
|
+
askUserDetails
|
|
1918
|
+
? h(AskUserDialog, { question: askUserDetails.question, options: askUserDetails.options, onSubmit: handleAskUserAnswer })
|
|
1919
|
+
: null,
|
|
1920
|
+
planDetails
|
|
1921
|
+
? h(PlanDialog, { plan: planDetails.plan, onConfirm: handlePlanConfirm })
|
|
1922
|
+
: null,
|
|
1923
|
+
)
|
|
1924
|
+
)
|
|
1925
|
+
: h(TranscriptViewport, {
|
|
1926
|
+
ref: transcriptScrollRef,
|
|
1927
|
+
height: middleMaxHeight,
|
|
1928
|
+
exchanges,
|
|
1929
|
+
userMessage: currentUserMessage,
|
|
1930
|
+
displayEvents: currentEvents,
|
|
1931
|
+
columns,
|
|
1932
|
+
expandedOutputIndexes,
|
|
1933
|
+
expandedThoughtIds,
|
|
1934
|
+
thoughtStreaming: state === 'thinking',
|
|
1935
|
+
animate: isTaskActive(),
|
|
1936
|
+
showDoneSeparator: state === 'done' || state === 'error',
|
|
1937
|
+
TaskSeparator,
|
|
1938
|
+
scrollAwayFromLive: !followLive,
|
|
1939
|
+
}),
|
|
1940
|
+
|
|
1941
|
+
/* ── Fixed bottom: processing, error, input (no dialogs) ── */
|
|
1942
|
+
h(Box, { flexShrink: 0, flexDirection: 'column', width: '100%' },
|
|
1943
|
+
|
|
1944
|
+
(state === 'streaming' || state === 'thinking' || state === 'tool_execution')
|
|
1945
|
+
? h(ProcessingPulse, { startAt: exchangeStartRef.current || Date.now() })
|
|
1946
|
+
: null,
|
|
1947
|
+
|
|
1948
|
+
loadingHistory ? h(Box, { paddingLeft: 2 }, h(LoadingDots, { text: 'Loading sessions' })) : null,
|
|
1949
|
+
|
|
1950
|
+
modeSwitching ? h(Box, { paddingLeft: 2 }, h(ModeSwitchAnimation, { modeLabel: modeSwitching })) : null,
|
|
1951
|
+
|
|
1952
|
+
state === 'error'
|
|
1953
|
+
? h(Box, { paddingY: 1, paddingLeft: 2 }, h(Text, { color: 'red' }, 'X An error occurred.'))
|
|
1954
|
+
: null,
|
|
1955
|
+
|
|
1956
|
+
/* ── Picker (footer, normal flex flow) — when no blocking modal is open ── */
|
|
1957
|
+
// [FIX-modal-overlap] Pickers used to be drawn with `position: 'absolute'`
|
|
1958
|
+
// at the bottom of the column, which left the transcript cells visible
|
|
1959
|
+
// behind them. Now they live in the flex footer, with the transcript
|
|
1960
|
+
// shrunk by `pickerRows` so the two never overlap.
|
|
1961
|
+
!isAgentBlockingModal && isPickerModal
|
|
1962
|
+
? h(ModalPanel, {},
|
|
1963
|
+
showSlashMenu ? h(SlashMenu, { visible: true, onSelect: handleSlashSelect, onCancel: handleSlashCancel }) : null,
|
|
1964
|
+
showModePicker ? h(ModePicker, { visible: true, onSelect: handleModeSelect, onCancel: handleModeCancel, currentMode: agentConfig.mode, currentExecutionMode: executionMode, hiddenModes }) : null,
|
|
1965
|
+
showProviderMenu ? h(ProviderMenu, { visible: true, isLocal, onSelect: handleProviderSelect, onCancel: handleProviderCancel, serverUrl: agentConfig.server, token: agentConfig.token, currentProvider }) : null,
|
|
1966
|
+
showSavePicker ? h(SavePicker, { visible: true, onSelect: handleSaveSelect, onCancel: handleSaveCancel, hasCloud: !!(agentConfig.server && agentConfig.token && !isLocal) }) : null,
|
|
1967
|
+
showHistoryPicker ? h(HistoryPicker, { sessions: historySessions, visible: true, onSelect: handleHistorySelect, onCancel: handleHistoryCancel }) : null,
|
|
1968
|
+
)
|
|
1969
|
+
: null,
|
|
1970
|
+
|
|
1971
|
+
/* ── Input area at bottom (hidden when modals or pickers are open) ── */
|
|
1972
|
+
!isAgentBlockingModal && !isPickerModal && !modeSwitching ? h(Box, { flexDirection: 'column' },
|
|
1973
|
+
h(InputFrameLine, { columns }),
|
|
1974
|
+
h(Box, { paddingX: 2, width: '100%' },
|
|
1975
|
+
h(InputField, {
|
|
1976
|
+
onSubmit: handleUserInput,
|
|
1977
|
+
placeholder: 'Type a message...',
|
|
1978
|
+
disabled: isInputDisabled(),
|
|
1979
|
+
onActivity: updateUserActivity
|
|
1980
|
+
})
|
|
1981
|
+
),
|
|
1982
|
+
h(InputFrameLine, { columns }),
|
|
1983
|
+
h(Box, { paddingLeft: 2, paddingTop: 1, flexDirection: 'row', flexWrap: 'wrap' },
|
|
1984
|
+
// 1. Mode badge (idle) OR running hints (streaming/thinking/tool_execution)
|
|
1985
|
+
!(state === 'streaming' || state === 'thinking' || state === 'tool_execution')
|
|
1986
|
+
? h(Text, { color: '#e0af68', bold: true }, `[${agentConfig.mode}/${executionMode}] `)
|
|
1987
|
+
: h(Box, { flexDirection: 'row' },
|
|
1988
|
+
h(Text, { color: '#565f89', dimColor: true }, ' · Press '),
|
|
1989
|
+
h(Text, { color: 'white' }, 'Esc'),
|
|
1990
|
+
h(Text, { color: '#565f89', dimColor: true }, ' to cancel a task'),
|
|
1991
|
+
state === 'thinking' ? h(Text, { color: '#565f89', dimColor: true }, ' · ') : null,
|
|
1992
|
+
state === 'thinking' ? h(Text, { color: 'white' }, 'Ctrl+O') : null,
|
|
1993
|
+
state === 'thinking' ? h(Text, { color: '#565f89', dimColor: true }, ' to toggle thinking') : null,
|
|
1994
|
+
),
|
|
1995
|
+
h(Box, { flexDirection: 'row', flexWrap: 'wrap' },
|
|
1996
|
+
h(Text, { color: 'white' }, '\u2590'),
|
|
1997
|
+
h(Text, { color: '#4a9eff' }, '\u25B3'),
|
|
1998
|
+
h(Text, { color: 'white' }, '\u258C'),
|
|
1999
|
+
isLocal
|
|
2000
|
+
? (currentProvider && currentProvider.type
|
|
2001
|
+
? h(Text, { color: '#9ece6a' }, ` ${currentProvider.type}${currentProvider.model ? '/' + currentProvider.model : ''} - `)
|
|
2002
|
+
: h(Text, { color: '#565f89', dimColor: true }, ' No provider - '))
|
|
2003
|
+
: h(Text, { color: '#565f89', dimColor: true }, ' OS AI AGENT - '),
|
|
2004
|
+
h(Text, { color: '#7aa2f7' }, deviceDisplayName || currentDirectory)
|
|
2005
|
+
),
|
|
2006
|
+
// 3. Spacer + scroll hints — only when NOT running (no point scrolling mid-task)
|
|
2007
|
+
!(state === 'streaming' || state === 'thinking' || state === 'tool_execution')
|
|
2008
|
+
? h(Box, { flexDirection: 'row' },
|
|
2009
|
+
h(Text, { color: '#565f89', dimColor: true }, ' '),
|
|
2010
|
+
h(Text, { color: '#565f89', dimColor: true }, 'Arrow '),
|
|
2011
|
+
h(Text, { color: 'white' }, 'Up'),
|
|
2012
|
+
h(Text, { color: '#565f89', dimColor: true }, '/'),
|
|
2013
|
+
h(Text, { color: 'white' }, 'Down'),
|
|
2014
|
+
h(Text, { color: '#565f89', dimColor: true }, ': scroll · PageUp/PageDown: page · End: follow live'),
|
|
2015
|
+
)
|
|
2016
|
+
: null,
|
|
2017
|
+
)
|
|
2018
|
+
) : null
|
|
2019
|
+
),
|
|
2020
|
+
|
|
2021
|
+
/* ── Modal dialogs: agent-blocking modals are rendered in the middle
|
|
2022
|
+
section above (with the transcript hidden). Pickers are rendered in
|
|
2023
|
+
the footer below. This keeps the layout in normal flex flow and
|
|
2024
|
+
prevents the transcript cells from leaking through the overlay. ── */
|
|
2025
|
+
) : null,
|
|
2026
|
+
|
|
2027
|
+
/* ── Logout Animation ── */
|
|
2028
|
+
loggingOut ? h(LogoutAnimation) : null
|
|
2029
|
+
|
|
2030
|
+
);
|
|
2031
|
+
}
|