pelulu-cli 1.1.0 → 1.2.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/package.json +8 -5
- package/src/agent/agent-controller.js +6 -1
- package/src/agent/agent-loop.js +169 -197
- package/src/agent/llm-client.js +6 -12
- package/src/core/config.js +1 -1
- package/src/core/http-client.js +285 -0
- package/src/core/job-manager.js +192 -0
- package/src/core/tool-registry.js +53 -2
- package/src/index.js +69 -7
- package/src/mcp/mcp-handler.js +35 -3
- package/src/mcp/mqtt-client.js +184 -13
- package/src/tools/file.js +8 -2
- package/src/tools/git.js +56 -6
- package/src/tools/jobs.js +93 -0
- package/src/tools/network.js +56 -23
- package/src/tools/project.js +38 -4
- package/src/tools/search.js +10 -14
- package/src/tools/shell.js +70 -4
- package/src/tui/ink-app.js +282 -22
- package/src/tui/ink-components.js +41 -18
- package/src/tui/renderer.js +1 -1
- package/docs/AGENT.md +0 -237
- package/specifications/xiaozhi-mqtt-broker.md +0 -46
package/src/tui/ink-app.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from './ink-components.js';
|
|
10
10
|
import { CompletableInput } from './completable-input.js';
|
|
11
11
|
import { setInkMode } from '../core/logger.js';
|
|
12
|
+
import { jobManager } from '../core/job-manager.js';
|
|
12
13
|
|
|
13
14
|
export function createApp({ registry, mqtt, stats, session, bus, config, extras }) {
|
|
14
15
|
return function App() {
|
|
@@ -28,13 +29,36 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
28
29
|
const maxMessages = 200;
|
|
29
30
|
const [scrollOffset, setScrollOffset] = useState(0); // 0 = bottom, N = scrolled up N messages
|
|
30
31
|
const scrollOffsetRef = useRef(0);
|
|
32
|
+
// Watchdog + background task phase tracking
|
|
33
|
+
const activityTimer = useRef(null); // LLM/agent "thinking" watchdog
|
|
34
|
+
const taskTimer = useRef(null); // long-running task liveness watchdog
|
|
35
|
+
const lastPhaseRef = useRef(null);
|
|
36
|
+
const taskRunningRef = useRef(false); // a background task is active
|
|
37
|
+
const agentBusyRef = useRef(false); // a XiaoZhi turn is actively in progress
|
|
38
|
+
const wasConnectedRef = useRef(mqtt.connected); // dedupe "connected" spam
|
|
39
|
+
const STUCK_TIMEOUT_MS = config?.agent?.stuck_timeout_ms || 45000;
|
|
40
|
+
// A single step can legitimately run a while; only warn if a task
|
|
41
|
+
// produces NO progress events at all for this long.
|
|
42
|
+
const TASK_STUCK_MS = config?.agent?.task_timeout_ms || 90000;
|
|
31
43
|
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
// Track terminal height so the whole UI always fits inside the viewport.
|
|
45
|
+
// If the total output is taller than the terminal, Ink cannot erase the
|
|
46
|
+
// previous frame and re-prints the top border on every render — this is
|
|
47
|
+
// the "duplicate banner" bug. Keeping the tree <= terminal rows avoids it.
|
|
48
|
+
const [rows, setRows] = useState(process.stdout.rows || 24);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const onResize = () => setRows(process.stdout.rows || 24);
|
|
51
|
+
process.stdout.on('resize', onResize);
|
|
52
|
+
return () => process.stdout.off('resize', onResize);
|
|
36
53
|
}, []);
|
|
37
54
|
|
|
55
|
+
// Chrome that is ALWAYS present around the message window:
|
|
56
|
+
// banner (7) + status bar (3) + input box (3) + message paddingY (2) = 15
|
|
57
|
+
// Dynamic single-line chrome (status line, thinking, scroll hints) is
|
|
58
|
+
// accounted for at render time so the frame never exceeds the terminal
|
|
59
|
+
// height — overflow is what causes the duplicated banner + vanishing chat.
|
|
60
|
+
const BASE_CHROME = 15;
|
|
61
|
+
|
|
38
62
|
// ─── Enable Ink mode for logger ──────────────────
|
|
39
63
|
useEffect(() => {
|
|
40
64
|
setInkMode(true, bus);
|
|
@@ -48,6 +72,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
48
72
|
useEffect(() => {
|
|
49
73
|
const onLlmText = (text) => {
|
|
50
74
|
setThinking('idle');
|
|
75
|
+
if (activityTimer.current) { clearTimeout(activityTimer.current); activityTimer.current = null; }
|
|
51
76
|
// Strip emojis to check if there's real content
|
|
52
77
|
const clean = text.replace(/\p{Emoji_Presentation}/gu, '').replace(/\p{Extended_Pictographic}/gu, '').trim();
|
|
53
78
|
if (!clean) return; // skip emoji-only LLM responses (TTS will carry the real text)
|
|
@@ -57,6 +82,8 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
57
82
|
};
|
|
58
83
|
|
|
59
84
|
const onToolCalled = ({ name, result, args }) => {
|
|
85
|
+
// A tool just finished -> refresh the watchdog (agent is alive)
|
|
86
|
+
if (activityTimer.current) { clearTimeout(activityTimer.current); activityTimer.current = null; }
|
|
60
87
|
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
61
88
|
id: `tool-${Date.now()}`, role: 'tool',
|
|
62
89
|
toolName: name, action: args?.action,
|
|
@@ -70,14 +97,164 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
70
97
|
}]);
|
|
71
98
|
};
|
|
72
99
|
|
|
73
|
-
|
|
100
|
+
// File change tracking — surface each tracked change as a chat line
|
|
101
|
+
const onFilesChanged = ({ path, change }) => {
|
|
102
|
+
const short = path.replace(process.cwd() + '/', '');
|
|
103
|
+
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
104
|
+
id: `track-${Date.now()}`, role: 'system',
|
|
105
|
+
content: `tracked: ${change} ${short}`,
|
|
106
|
+
}]);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const pushSystem = (content) => setMessages(prev => [...prev.slice(-maxMessages), {
|
|
110
|
+
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, role: 'system', content,
|
|
111
|
+
}]);
|
|
112
|
+
|
|
113
|
+
// Watchdog: if the agent is "busy" but nothing happens for a while,
|
|
114
|
+
// recover the UI instead of hanging forever on ".. using tool...".
|
|
115
|
+
const clearWatchdog = () => {
|
|
116
|
+
if (activityTimer.current) { clearTimeout(activityTimer.current); activityTimer.current = null; }
|
|
117
|
+
};
|
|
118
|
+
const armWatchdog = () => {
|
|
119
|
+
clearWatchdog();
|
|
120
|
+
activityTimer.current = setTimeout(() => {
|
|
121
|
+
setThinking('idle');
|
|
122
|
+
pushSystem('still waiting on XiaoZhi to reply — you can keep typing meanwhile.');
|
|
123
|
+
}, STUCK_TIMEOUT_MS);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const onThinking = ({ state }) => {
|
|
127
|
+
// While a background task owns the UI, ignore transient
|
|
128
|
+
// "thinking/tool_call" states so we never get stuck on ".. using tool..".
|
|
129
|
+
if (taskRunningRef.current) return;
|
|
130
|
+
setThinking(state);
|
|
131
|
+
if (state && state !== 'idle') armWatchdog();
|
|
132
|
+
else clearWatchdog();
|
|
133
|
+
};
|
|
74
134
|
const onReady = () => {
|
|
75
135
|
setConnected(true);
|
|
76
136
|
setSessionId(mqtt.sessionId);
|
|
137
|
+
// Only announce a real disconnected -> connected transition, so
|
|
138
|
+
// reconnects / repeated hellos don't spam and look like a reset.
|
|
139
|
+
if (!wasConnectedRef.current) pushSystem('connected to XiaoZhi.');
|
|
140
|
+
wasConnectedRef.current = true;
|
|
77
141
|
};
|
|
78
|
-
const onDisconnect = () => {
|
|
142
|
+
const onDisconnect = (err) => {
|
|
79
143
|
setConnected(false);
|
|
80
144
|
setSessionId(null);
|
|
145
|
+
setThinking('idle');
|
|
146
|
+
clearWatchdog();
|
|
147
|
+
lastPhaseRef.current = null;
|
|
148
|
+
if (wasConnectedRef.current) {
|
|
149
|
+
pushSystem(`disconnected${err?.message ? ` (${err.message})` : ''} — reconnecting...`);
|
|
150
|
+
}
|
|
151
|
+
wasConnectedRef.current = false;
|
|
152
|
+
};
|
|
153
|
+
const onSessionEnd = (info) => {
|
|
154
|
+
// XiaoZhi ended the audio session (idle `goodbye`). The next message now
|
|
155
|
+
// auto-reconnects (see mqtt.ensureSession), so this is silent unless the
|
|
156
|
+
// user is waiting.
|
|
157
|
+
setSessionId(null);
|
|
158
|
+
// Don't nag while work is still in progress. A `goodbye` that lands mid
|
|
159
|
+
// turn is handled by mqtt-client (it re-opens the session so the current
|
|
160
|
+
// task keeps talking to XiaoZhi); showing "session paused" here would be
|
|
161
|
+
// wrong and alarming. Background tasks run locally and don't need the
|
|
162
|
+
// session either.
|
|
163
|
+
if (taskRunningRef.current || agentBusyRef.current || info?.turnActive) return;
|
|
164
|
+
setThinking('idle');
|
|
165
|
+
clearWatchdog();
|
|
166
|
+
// Surface a subtle, reassuring notice so a paused session doesn't look
|
|
167
|
+
// like a silent reset — the next message reconnects automatically.
|
|
168
|
+
pushSystem('XiaoZhi session paused — it resumes automatically when you send your next message.');
|
|
169
|
+
};
|
|
170
|
+
const onSessionDead = () => {
|
|
171
|
+
// ensureSession() failed to re-establish — tell the user explicitly
|
|
172
|
+
// instead of silently dropping their message.
|
|
173
|
+
setThinking('idle');
|
|
174
|
+
clearWatchdog();
|
|
175
|
+
pushSystem('could not reach XiaoZhi (session lost) — check connection and try again.');
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Reconnection lifecycle — keep the user informed instead of silently
|
|
179
|
+
// dropping the connection (the old "disconnected forever" symptom).
|
|
180
|
+
const onReconnecting = (data) => {
|
|
181
|
+
setConnected(false);
|
|
182
|
+
setSessionId(null);
|
|
183
|
+
pushSystem(`reconnecting to XiaoZhi (attempt ${data?.attempt || 1})...`);
|
|
184
|
+
wasConnectedRef.current = false;
|
|
185
|
+
};
|
|
186
|
+
const onReconnected = () => {
|
|
187
|
+
setConnected(true);
|
|
188
|
+
setSessionId(mqtt.sessionId);
|
|
189
|
+
pushSystem('reconnected to XiaoZhi.');
|
|
190
|
+
wasConnectedRef.current = true;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Long-running task liveness watchdog — reset on EVERY progress event.
|
|
194
|
+
// This replaces the old per-phase timer that false-fired during slow
|
|
195
|
+
// steps (the bogus "no response (timed out)" mid-task).
|
|
196
|
+
const bumpTaskWatchdog = (label) => {
|
|
197
|
+
if (taskTimer.current) clearTimeout(taskTimer.current);
|
|
198
|
+
taskTimer.current = setTimeout(() => {
|
|
199
|
+
taskRunningRef.current = false;
|
|
200
|
+
setThinking('idle');
|
|
201
|
+
pushSystem(`${label} appears stalled (no progress ${Math.round(TASK_STUCK_MS / 1000)}s) — check with "jobs" tool or try again.`);
|
|
202
|
+
}, TASK_STUCK_MS);
|
|
203
|
+
};
|
|
204
|
+
const clearTaskWatchdog = () => {
|
|
205
|
+
if (taskTimer.current) { clearTimeout(taskTimer.current); taskTimer.current = null; }
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// Background task progress -> surface inline in the chat
|
|
209
|
+
const onTaskProgress = (data) => {
|
|
210
|
+
if (!data) return;
|
|
211
|
+
const label = data.target || data.tool || 'task';
|
|
212
|
+
setLogLine(`[${data.tool || 'task'}] ${data.target || ''} ${data.phase || ''} ${data.elapsed || 0}s`.trim());
|
|
213
|
+
|
|
214
|
+
if (data.running) {
|
|
215
|
+
taskRunningRef.current = true;
|
|
216
|
+
setThinking('idle'); // never show the generic "using tool"
|
|
217
|
+
bumpTaskWatchdog(label); // any event = still alive
|
|
218
|
+
// Announce each new phase inline
|
|
219
|
+
if (data.phase && data.phase !== lastPhaseRef.current) {
|
|
220
|
+
lastPhaseRef.current = data.phase;
|
|
221
|
+
pushSystem(`» ${label}: ${data.phase}${data.log ? ` — ${data.log}` : ''}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!data.running || data.phase === 'done') {
|
|
226
|
+
clearTaskWatchdog();
|
|
227
|
+
taskRunningRef.current = false;
|
|
228
|
+
if (lastPhaseRef.current !== 'done') {
|
|
229
|
+
lastPhaseRef.current = 'done';
|
|
230
|
+
pushSystem(`» ${label}: finished`);
|
|
231
|
+
}
|
|
232
|
+
setThinking('idle');
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// ─── Universal background-job feedback ───────────
|
|
237
|
+
// Any tool action that runs past the grace window becomes a pollable
|
|
238
|
+
// background job. We announce it, stream its progress on the log line,
|
|
239
|
+
// and confirm completion inline — so EVERY tool gives continuous
|
|
240
|
+
// feedback and never looks "stuck".
|
|
241
|
+
const bgJobs = new Set();
|
|
242
|
+
const onJobBackgrounded = (snap) => {
|
|
243
|
+
if (!snap) return;
|
|
244
|
+
bgJobs.add(snap.id);
|
|
245
|
+
setThinking('idle');
|
|
246
|
+
pushSystem(`~ ${snap.label} is running in the background (${snap.id}); I'll report when it finishes.`);
|
|
247
|
+
};
|
|
248
|
+
const onJobProgress = (p) => {
|
|
249
|
+
if (!p) return;
|
|
250
|
+
setLogLine(`[${p.tool}] ${p.message}`.slice(0, 120));
|
|
251
|
+
};
|
|
252
|
+
const onJobDone = (snap) => {
|
|
253
|
+
if (!snap || !bgJobs.has(snap.id)) return; // only announce jobs we flagged as background
|
|
254
|
+
bgJobs.delete(snap.id);
|
|
255
|
+
if (snap.status === 'error') { pushSystem(`x ${snap.label} failed: ${snap.error}`); return; }
|
|
256
|
+
if (snap.status === 'cancelled') { pushSystem(`- ${snap.label} cancelled.`); return; }
|
|
257
|
+
pushSystem(`+ ${snap.label} finished in ${snap.elapsed_s}s.`);
|
|
81
258
|
};
|
|
82
259
|
|
|
83
260
|
// Single log line that updates in place
|
|
@@ -98,6 +275,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
98
275
|
const onTtsSentence = (text) => {
|
|
99
276
|
ttsBuffer += text;
|
|
100
277
|
setThinking('idle');
|
|
278
|
+
if (activityTimer.current) { clearTimeout(activityTimer.current); activityTimer.current = null; }
|
|
101
279
|
// Debounce: wait for all TTS sentences to arrive, then display
|
|
102
280
|
if (ttsTimer) clearTimeout(ttsTimer);
|
|
103
281
|
ttsTimer = setTimeout(() => {
|
|
@@ -119,39 +297,81 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
119
297
|
// Agent progress events
|
|
120
298
|
const onAgentProgress = ({ state, message, tool, action }) => {
|
|
121
299
|
if (state === 'tool') {
|
|
300
|
+
agentBusyRef.current = true;
|
|
122
301
|
setThinking('tool_call');
|
|
123
302
|
setLogLine(`[TOOL] ${tool}.${action || ''}...`);
|
|
303
|
+
armWatchdog();
|
|
124
304
|
} else if (state === 'tool_done') {
|
|
305
|
+
agentBusyRef.current = true;
|
|
125
306
|
setThinking('thinking');
|
|
126
307
|
setLogLine(`[TOOL] done, waiting...`);
|
|
308
|
+
armWatchdog();
|
|
127
309
|
} else if (state === 'receiving') {
|
|
310
|
+
agentBusyRef.current = true;
|
|
128
311
|
setLogLine(`[LLM] ${message}`);
|
|
312
|
+
armWatchdog();
|
|
129
313
|
} else if (state === 'thinking') {
|
|
314
|
+
agentBusyRef.current = true;
|
|
130
315
|
setThinking('thinking');
|
|
316
|
+
armWatchdog();
|
|
317
|
+
} else if (state === 'done') {
|
|
318
|
+
// The turn finished. Without this branch the watchdog armed by the
|
|
319
|
+
// last tool_done/receiving event was never cleared, so it fired
|
|
320
|
+
// ~45s later as a bogus "still waiting on XiaoZhi to reply" even
|
|
321
|
+
// though XiaoZhi had already answered. Clear it and go idle.
|
|
322
|
+
agentBusyRef.current = false;
|
|
323
|
+
setThinking('idle');
|
|
324
|
+
clearWatchdog();
|
|
131
325
|
} else if (state === 'timeout') {
|
|
326
|
+
agentBusyRef.current = false;
|
|
327
|
+
setThinking('idle');
|
|
328
|
+
clearWatchdog();
|
|
132
329
|
setLogLine(`[WARN] ${message}`);
|
|
330
|
+
pushSystem(`timeout: ${message}`);
|
|
133
331
|
}
|
|
134
332
|
};
|
|
135
333
|
|
|
136
334
|
bus.on('llm:text', onLlmText);
|
|
137
335
|
bus.on('tts:sentence', onTtsSentence);
|
|
138
336
|
bus.on('tool:called', onToolCalled);
|
|
337
|
+
bus.on('files:changed', onFilesChanged);
|
|
139
338
|
bus.on('thinking', onThinking);
|
|
140
339
|
bus.on('ready', onReady);
|
|
340
|
+
bus.on('session:end', onSessionEnd);
|
|
341
|
+
bus.on('session:dead', onSessionDead);
|
|
141
342
|
bus.on('mqtt:error', onDisconnect);
|
|
343
|
+
bus.on('mqtt:disconnected', onDisconnect);
|
|
344
|
+
bus.on('mqtt:reconnecting', onReconnecting);
|
|
345
|
+
bus.on('mqtt:reconnected', onReconnected);
|
|
142
346
|
bus.on('log:message', onLogMessage);
|
|
143
347
|
bus.on('agent:progress', onAgentProgress);
|
|
348
|
+
bus.on('job:backgrounded', onJobBackgrounded);
|
|
349
|
+
bus.on('job:progress', onJobProgress);
|
|
350
|
+
bus.on('job:done', onJobDone);
|
|
351
|
+
bus.on('task:progress', onTaskProgress);
|
|
144
352
|
|
|
145
353
|
return () => {
|
|
146
354
|
bus.off('llm:text', onLlmText);
|
|
147
355
|
bus.off('tts:sentence', onTtsSentence);
|
|
148
356
|
bus.off('tool:called', onToolCalled);
|
|
357
|
+
bus.off('files:changed', onFilesChanged);
|
|
149
358
|
bus.off('thinking', onThinking);
|
|
150
359
|
bus.off('ready', onReady);
|
|
360
|
+
bus.off('session:end', onSessionEnd);
|
|
361
|
+
bus.off('session:dead', onSessionDead);
|
|
151
362
|
bus.off('mqtt:error', onDisconnect);
|
|
363
|
+
bus.off('mqtt:disconnected', onDisconnect);
|
|
364
|
+
bus.off('mqtt:reconnecting', onReconnecting);
|
|
365
|
+
bus.off('mqtt:reconnected', onReconnected);
|
|
152
366
|
bus.off('log:message', onLogMessage);
|
|
153
367
|
bus.off('agent:progress', onAgentProgress);
|
|
368
|
+
bus.off('job:backgrounded', onJobBackgrounded);
|
|
369
|
+
bus.off('job:progress', onJobProgress);
|
|
370
|
+
bus.off('job:done', onJobDone);
|
|
371
|
+
bus.off('task:progress', onTaskProgress);
|
|
154
372
|
if (ttsTimer) clearTimeout(ttsTimer);
|
|
373
|
+
if (activityTimer.current) clearTimeout(activityTimer.current);
|
|
374
|
+
if (taskTimer.current) clearTimeout(taskTimer.current);
|
|
155
375
|
};
|
|
156
376
|
}, []);
|
|
157
377
|
|
|
@@ -228,6 +448,16 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
228
448
|
}]);
|
|
229
449
|
return;
|
|
230
450
|
}
|
|
451
|
+
if (cmd === '/files' || cmd === '/changes') {
|
|
452
|
+
const changes = extras.fileTracker?.getChanges() || [];
|
|
453
|
+
const body = changes.length
|
|
454
|
+
? changes.map(c => ` ${c.action} ${c.path.replace(process.cwd() + '/', '')}${c.count > 1 ? ` (${c.count}x)` : ''}`).join('\n')
|
|
455
|
+
: 'No file changes this session';
|
|
456
|
+
setMessages(prev => [...prev, {
|
|
457
|
+
id: `sys-${Date.now()}`, role: 'system', content: `file changes:\n${body}`,
|
|
458
|
+
}]);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
231
461
|
if (cmd === '/tools') {
|
|
232
462
|
const tools = registry.list();
|
|
233
463
|
const lines = tools.map(t => ` ${t.name} (${t.actions?.length || 0})`).join('\n');
|
|
@@ -257,10 +487,23 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
257
487
|
const intent = parseIntent(text);
|
|
258
488
|
if (intent.matched) {
|
|
259
489
|
setThinking('tool_call');
|
|
260
|
-
|
|
490
|
+
// Route through the job layer: fast commands render inline as before,
|
|
491
|
+
// slow ones self-background (announced + confirmed by the job:* handlers)
|
|
492
|
+
// so the TUI never freezes on a long local action.
|
|
493
|
+
const dispatched = await jobManager.dispatch(
|
|
494
|
+
{ tool: intent.tool, action: intent.action, label: `${intent.tool}.${intent.action || ''}` },
|
|
495
|
+
() => registry.call(intent.tool, intent.params),
|
|
496
|
+
);
|
|
261
497
|
setThinking('idle');
|
|
498
|
+
if (!dispatched.done) return; // backgrounded — job:* handlers take over
|
|
499
|
+
if (dispatched.error) {
|
|
500
|
+
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
501
|
+
id: `err-${Date.now()}`, role: 'system', content: `x ${intent.tool} failed: ${dispatched.error.message}`,
|
|
502
|
+
}]);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
262
505
|
const { formatToolResult } = await import('../core/formatter.js');
|
|
263
|
-
const formatted = formatToolResult(intent.tool, intent.action, result);
|
|
506
|
+
const formatted = formatToolResult(intent.tool, intent.action, dispatched.result);
|
|
264
507
|
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
265
508
|
id: `tool-${Date.now()}`, role: 'tool',
|
|
266
509
|
toolName: intent.tool, action: intent.action,
|
|
@@ -283,16 +526,14 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
283
526
|
await new Promise(r => setTimeout(r, 500));
|
|
284
527
|
}
|
|
285
528
|
|
|
286
|
-
// Use agent controller for proper response handling
|
|
529
|
+
// Use agent controller for proper response handling.
|
|
530
|
+
// NOTE: XiaoZhi's actual reply is rendered live by the `tts:sentence` /
|
|
531
|
+
// `llm:text` handlers above, so we must NOT push result.result here or
|
|
532
|
+
// the final message would appear twice. The controller run is awaited
|
|
533
|
+
// purely to drive the think→act loop and the thinking indicator.
|
|
287
534
|
try {
|
|
288
|
-
|
|
535
|
+
await agentController.run(text, { generatePlan: false });
|
|
289
536
|
setThinking('idle');
|
|
290
|
-
|
|
291
|
-
if (result.success && result.result) {
|
|
292
|
-
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
293
|
-
id: `assistant-${Date.now()}`, role: 'assistant', content: result.result,
|
|
294
|
-
}]);
|
|
295
|
-
}
|
|
296
537
|
} catch (err) {
|
|
297
538
|
setThinking('idle');
|
|
298
539
|
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
@@ -300,8 +541,15 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
300
541
|
}]);
|
|
301
542
|
}
|
|
302
543
|
} else {
|
|
303
|
-
// Fallback: send directly to XiaoZhi
|
|
304
|
-
mqtt.sendText(text);
|
|
544
|
+
// Fallback: send directly to XiaoZhi (auto-reconnects if session died)
|
|
545
|
+
const sent = await mqtt.sendText(text);
|
|
546
|
+
setThinking('idle');
|
|
547
|
+
if (!sent) {
|
|
548
|
+
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
549
|
+
id: `error-${Date.now()}`, role: 'system',
|
|
550
|
+
content: 'could not reach XiaoZhi — session lost, try again.',
|
|
551
|
+
}]);
|
|
552
|
+
}
|
|
305
553
|
}
|
|
306
554
|
}, [registry, mqtt, stats, session]);
|
|
307
555
|
|
|
@@ -309,8 +557,18 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
309
557
|
const tools = registry.all();
|
|
310
558
|
const actions = tools.reduce((s, t) => s + (t.actions?.length || 0), 0);
|
|
311
559
|
|
|
312
|
-
//
|
|
313
|
-
|
|
560
|
+
// Dynamic chrome: base + the single-line widgets actually shown this frame,
|
|
561
|
+
// plus a reserve of 2 rows for the (possible) scroll-up/down hints. This
|
|
562
|
+
// guarantees banner + status + messages + input never exceed the terminal
|
|
563
|
+
// height, which is what caused the duplicated banner and vanishing chat.
|
|
564
|
+
const dynamicChrome =
|
|
565
|
+
BASE_CHROME +
|
|
566
|
+
(logLine ? 1 : 0) +
|
|
567
|
+
(thinking && thinking !== 'idle' ? 1 : 0) +
|
|
568
|
+
2;
|
|
569
|
+
const MAX_RENDER_LINES = Math.max(3, rows - dynamicChrome);
|
|
570
|
+
|
|
571
|
+
// Calculate visible messages (scrollable window sized to terminal height)
|
|
314
572
|
const totalMessages = messages.length;
|
|
315
573
|
const endIdx = totalMessages - scrollOffset;
|
|
316
574
|
|
|
@@ -342,11 +600,13 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
342
600
|
return React.createElement(Box, {
|
|
343
601
|
flexDirection: 'column', width: '100%',
|
|
344
602
|
},
|
|
345
|
-
// Top: Banner + Status bar
|
|
603
|
+
// Top: Banner + Status bar (always visible / pinned)
|
|
346
604
|
React.createElement(AsciiBanner, {
|
|
605
|
+
key: 'ascii-banner',
|
|
347
606
|
version: config?.agent?.version,
|
|
348
607
|
}),
|
|
349
608
|
React.createElement(StatusBar, {
|
|
609
|
+
key: 'status-bar',
|
|
350
610
|
connected, session: sessionId,
|
|
351
611
|
}),
|
|
352
612
|
|
|
@@ -361,7 +621,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
361
621
|
canScrollUp
|
|
362
622
|
? React.createElement(Box, { paddingLeft: 2 },
|
|
363
623
|
React.createElement(Text, { dimColor: true, color: 'yellow' },
|
|
364
|
-
`[${
|
|
624
|
+
`[${startIdx} more above | Shift+Up/Down or PgUp/PgDn to scroll]`
|
|
365
625
|
),
|
|
366
626
|
)
|
|
367
627
|
: null,
|
|
@@ -24,49 +24,72 @@ async function readPkgVersion() {
|
|
|
24
24
|
readPkgVersion();
|
|
25
25
|
|
|
26
26
|
// ─── ASCII Banner ────────────────────────────────────────
|
|
27
|
-
// Memoized — only re-renders when version prop changes
|
|
27
|
+
// Memoized — only re-renders when version prop changes.
|
|
28
|
+
// Uses useMemo to cache the element reference so Ink's reconciler
|
|
29
|
+
// skips re-rendering on every parent state change (fixes banner duplication).
|
|
28
30
|
export const AsciiBanner = React.memo(function AsciiBanner({ version }) {
|
|
29
|
-
const v = version || '0.0.0';
|
|
31
|
+
const v = version || _pkgVersion || '0.0.0';
|
|
32
|
+
|
|
33
|
+
const cat = [
|
|
34
|
+
' /\\_/\\ ',
|
|
35
|
+
' ( o.o ) ',
|
|
36
|
+
' > ^ < ',
|
|
37
|
+
' /| |\\ ',
|
|
38
|
+
' (_| |_)',
|
|
39
|
+
];
|
|
40
|
+
const info = [
|
|
41
|
+
'P E L U L U - C L I',
|
|
42
|
+
`v${v}`,
|
|
43
|
+
'the tiny coding companion',
|
|
44
|
+
'powered by XiaoZhi',
|
|
45
|
+
'',
|
|
46
|
+
];
|
|
47
|
+
|
|
30
48
|
return React.createElement(Box, {
|
|
31
|
-
flexDirection: 'column',
|
|
49
|
+
flexDirection: 'column', width: '100%',
|
|
50
|
+
borderStyle: 'single', borderColor: 'cyan',
|
|
51
|
+
paddingY: 0, paddingX: 0,
|
|
32
52
|
},
|
|
33
|
-
|
|
34
|
-
React.createElement(
|
|
35
|
-
|
|
53
|
+
...cat.map((line, i) =>
|
|
54
|
+
React.createElement(Box, { key: i, paddingLeft: 1 },
|
|
55
|
+
React.createElement(Text, { color: 'cyan' }, line),
|
|
56
|
+
React.createElement(Text, {
|
|
57
|
+
color: i === 0 ? 'cyan' : i === 1 ? 'gray' : i === 2 ? 'cyanBright' : 'gray',
|
|
58
|
+
bold: i === 0,
|
|
59
|
+
}, ' ' + (info[i] || '')),
|
|
60
|
+
)
|
|
61
|
+
),
|
|
62
|
+
React.createElement(Box, { paddingLeft: 1 },
|
|
63
|
+
React.createElement(Text, { dimColor: true }, ' 18 tools • MCP protocol • agent mode • xiaozhi.me'),
|
|
36
64
|
),
|
|
37
|
-
React.createElement(Text, { color: 'cyan' }, ' ( o.o ) v' + v),
|
|
38
|
-
React.createElement(Text, { color: 'cyan' }, ' > ^ < the tiny coding agent'),
|
|
39
|
-
React.createElement(Text, { color: 'cyan' }, ' /| |\\ powered by XiaoZhi'),
|
|
40
|
-
React.createElement(Text, { color: 'cyan' }, '(_| |_)'),
|
|
41
|
-
React.createElement(Text, { dimColor: true }, ' 18 tools • MCP protocol • agent mode'),
|
|
42
65
|
);
|
|
43
66
|
});
|
|
44
67
|
|
|
45
68
|
// ─── Status Bar ───────────────────────────────────────────
|
|
46
|
-
//
|
|
69
|
+
// Uses useMemo to cache the element — avoids re-rendering on parent state changes
|
|
47
70
|
export const StatusBar = React.memo(function StatusBar({ connected, session }) {
|
|
48
71
|
const statusDot = connected ? '●' : '○';
|
|
49
72
|
const statusColor = connected ? 'green' : 'red';
|
|
50
73
|
const sess = session ? session.slice(0, 8) : '---';
|
|
74
|
+
const label = connected ? ' online' : ' offline';
|
|
51
75
|
|
|
52
|
-
|
|
76
|
+
const element = React.useMemo(() => React.createElement(Box, {
|
|
53
77
|
borderStyle: 'single', borderColor: 'cyan', width: '100%',
|
|
54
78
|
paddingX: 1, paddingY: 0,
|
|
55
79
|
flexDirection: 'row', justifyContent: 'space-between',
|
|
56
80
|
},
|
|
57
81
|
React.createElement(Box, { flexDirection: 'row' },
|
|
58
|
-
React.createElement(Text, { color: 'cyan', bold: true }, '
|
|
82
|
+
React.createElement(Text, { color: 'cyan', bold: true }, 'PELULU '),
|
|
59
83
|
React.createElement(Text, { color: statusColor }, statusDot),
|
|
60
|
-
React.createElement(Text, { color: statusColor, bold: connected },
|
|
61
|
-
connected ? ' online' : ' offline'
|
|
62
|
-
),
|
|
84
|
+
React.createElement(Text, { color: statusColor, bold: connected }, label),
|
|
63
85
|
),
|
|
64
86
|
React.createElement(Box, { flexDirection: 'row' },
|
|
65
87
|
React.createElement(Text, { dimColor: true }, `session:${sess}`),
|
|
66
88
|
React.createElement(Text, { dimColor: true }, ' '),
|
|
67
89
|
React.createElement(Text, { dimColor: true }, 'xiaozhi.me'),
|
|
68
90
|
),
|
|
69
|
-
);
|
|
91
|
+
), [connected, sess]);
|
|
92
|
+
return element;
|
|
70
93
|
});
|
|
71
94
|
|
|
72
95
|
// ─── Strip Emojis ─────────────────────────────────────────
|
package/src/tui/renderer.js
CHANGED
|
@@ -65,7 +65,7 @@ export async function renderAsciiBanner() {
|
|
|
65
65
|
const info = [
|
|
66
66
|
chalk.cyan.bold('P E L U L U - C L I'),
|
|
67
67
|
chalk.gray(`v${version}`),
|
|
68
|
-
chalk.cyan('coding companion'),
|
|
68
|
+
chalk.cyan('the tiny coding companion'),
|
|
69
69
|
chalk.gray('powered by XiaoZhi'),
|
|
70
70
|
'',
|
|
71
71
|
];
|