pelulu-cli 1.0.5 → 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/CHANGELOG.md +81 -0
- package/package.json +10 -6
- package/src/agent/agent-controller.js +105 -0
- package/src/agent/agent-loop.js +248 -0
- package/src/agent/context-builder.js +307 -0
- package/src/agent/history-condenser.js +202 -0
- package/src/agent/index.js +8 -0
- package/src/agent/llm-client.js +44 -0
- package/src/agent/plan-manager.js +342 -0
- package/src/agent/system-prompt.js +41 -0
- 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/logger.js +106 -1
- package/src/core/system-prompt.js +24 -8
- package/src/core/tool-registry.js +65 -6
- package/src/index.js +151 -29
- package/src/mcp/mcp-handler.js +63 -4
- package/src/mcp/mqtt-client.js +195 -12
- package/src/tools/agent.js +80 -0
- 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/completable-input.js +49 -42
- package/src/tui/ink-app.js +358 -19
- package/src/tui/ink-components.js +82 -12
- package/src/tui/ink-entry.js +26 -2
- package/src/tui/renderer.js +80 -39
|
@@ -1,68 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CompletableInput — TextInput with Tab auto-completion
|
|
2
|
+
* CompletableInput — TextInput with Tab auto-completion + char counter
|
|
3
3
|
* Wraps ink-text-input and adds completion support
|
|
4
4
|
*/
|
|
5
|
-
import React, { useState, useCallback } from 'react';
|
|
5
|
+
import React, { useState, useCallback, useEffect } from 'react';
|
|
6
6
|
import { Box, Text, useInput } from 'ink';
|
|
7
7
|
import TextInput from 'ink-text-input';
|
|
8
8
|
import { getCompletions } from '../core/completion.js';
|
|
9
9
|
|
|
10
|
+
const MAX_CHARS = 70;
|
|
11
|
+
|
|
10
12
|
export function CompletableInput({ onSubmit, placeholder }) {
|
|
11
13
|
const [value, setValue] = useState('');
|
|
12
14
|
const [completions, setCompletions] = useState([]);
|
|
13
15
|
const [completionIndex, setCompletionIndex] = useState(0);
|
|
14
16
|
const [showCompletions, setShowCompletions] = useState(false);
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
const isAtLimit = value.length >= MAX_CHARS;
|
|
19
|
+
const charCount = value.length;
|
|
20
|
+
const remaining = MAX_CHARS - charCount;
|
|
21
|
+
|
|
22
|
+
// Handle Tab/Escape/Return — regular chars are handled by TextInput
|
|
17
23
|
useInput((input, key) => {
|
|
18
24
|
if (key.tab) {
|
|
19
25
|
if (!showCompletions) {
|
|
20
|
-
// First Tab: show completions
|
|
21
26
|
const hits = getCompletions(value);
|
|
22
27
|
if (hits.length > 0) {
|
|
23
28
|
setCompletions(hits);
|
|
24
29
|
setCompletionIndex(0);
|
|
25
30
|
setShowCompletions(true);
|
|
26
|
-
// Auto-fill first match
|
|
27
31
|
if (hits.length === 1) {
|
|
28
|
-
|
|
32
|
+
const filled = hits[0] + ' ';
|
|
33
|
+
if (filled.length <= MAX_CHARS) {
|
|
34
|
+
setValue(filled);
|
|
35
|
+
}
|
|
29
36
|
setShowCompletions(false);
|
|
30
37
|
} else {
|
|
31
|
-
setValue(hits[0]);
|
|
38
|
+
setValue(hits[0].slice(0, MAX_CHARS));
|
|
32
39
|
}
|
|
33
40
|
}
|
|
34
41
|
} else {
|
|
35
|
-
// Subsequent Tab: cycle through completions
|
|
36
42
|
const next = (completionIndex + 1) % completions.length;
|
|
37
43
|
setCompletionIndex(next);
|
|
38
|
-
setValue(completions[next]);
|
|
44
|
+
setValue(completions[next].slice(0, MAX_CHARS));
|
|
39
45
|
}
|
|
40
46
|
} else if (key.escape) {
|
|
41
|
-
// Escape: hide completions
|
|
42
47
|
setShowCompletions(false);
|
|
43
48
|
setCompletions([]);
|
|
44
49
|
setCompletionIndex(0);
|
|
45
50
|
} else if (key.return) {
|
|
46
|
-
// Enter: handled by TextInput onSubmit, do nothing here
|
|
47
51
|
setShowCompletions(false);
|
|
48
52
|
setCompletions([]);
|
|
49
|
-
} else {
|
|
50
|
-
// Any other key: reset completions
|
|
51
|
-
if (showCompletions) {
|
|
52
|
-
setShowCompletions(false);
|
|
53
|
-
setCompletions([]);
|
|
54
|
-
setCompletionIndex(0);
|
|
55
|
-
}
|
|
56
53
|
}
|
|
54
|
+
// All other keys: let TextInput handle them (no-op here)
|
|
57
55
|
});
|
|
58
56
|
|
|
59
|
-
// Update value from TextInput
|
|
57
|
+
// Update value from TextInput — stable reference, no deps
|
|
60
58
|
const handleChange = useCallback((val) => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
const trimmed = val.slice(0, MAX_CHARS);
|
|
60
|
+
setValue(trimmed);
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
// Derive completions from value (debounced via useEffect to avoid
|
|
64
|
+
// extra re-renders on every character)
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (value.length > 0) {
|
|
67
|
+
const hits = getCompletions(value);
|
|
68
|
+
if (hits.length > 0 && hits[0] !== value) {
|
|
66
69
|
setCompletions(hits);
|
|
67
70
|
setShowCompletions(true);
|
|
68
71
|
setCompletionIndex(0);
|
|
@@ -72,9 +75,9 @@ export function CompletableInput({ onSubmit, placeholder }) {
|
|
|
72
75
|
} else {
|
|
73
76
|
setShowCompletions(false);
|
|
74
77
|
}
|
|
75
|
-
}, []);
|
|
78
|
+
}, [value]);
|
|
76
79
|
|
|
77
|
-
// Handle TextInput submit
|
|
80
|
+
// Handle TextInput submit
|
|
78
81
|
const handleSubmit = useCallback((val) => {
|
|
79
82
|
const trimmed = val.trim();
|
|
80
83
|
if (trimmed) {
|
|
@@ -85,6 +88,9 @@ export function CompletableInput({ onSubmit, placeholder }) {
|
|
|
85
88
|
setCompletions([]);
|
|
86
89
|
}, [onSubmit]);
|
|
87
90
|
|
|
91
|
+
// Color for char counter
|
|
92
|
+
const counterColor = isAtLimit ? 'red' : remaining <= 10 ? 'yellow' : 'dim';
|
|
93
|
+
|
|
88
94
|
return React.createElement(Box, { flexDirection: 'column', width: '100%' },
|
|
89
95
|
// Completion suggestions
|
|
90
96
|
showCompletions && completions.length > 1
|
|
@@ -105,23 +111,24 @@ export function CompletableInput({ onSubmit, placeholder }) {
|
|
|
105
111
|
)
|
|
106
112
|
: null,
|
|
107
113
|
|
|
108
|
-
// Input line
|
|
109
|
-
React.createElement(Box, { paddingX: 1 },
|
|
114
|
+
// Input line with char counter
|
|
115
|
+
React.createElement(Box, { paddingX: 1, flexDirection: 'row', alignItems: 'center' },
|
|
110
116
|
React.createElement(Text, { color: 'cyan' }, '> '),
|
|
111
|
-
React.createElement(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
117
|
+
React.createElement(Box, { flexDirection: 'column', flexGrow: 1 },
|
|
118
|
+
React.createElement(TextInput, {
|
|
119
|
+
value,
|
|
120
|
+
onChange: handleChange,
|
|
121
|
+
onSubmit: handleSubmit,
|
|
122
|
+
placeholder: isAtLimit ? '⚠️ limit reached!' : (placeholder || 'type a message...'),
|
|
123
|
+
showCursor: !showCompletions || completions.length <= 1,
|
|
124
|
+
}),
|
|
125
|
+
),
|
|
126
|
+
// Char counter
|
|
127
|
+
React.createElement(Box, { marginLeft: 1 },
|
|
128
|
+
React.createElement(Text, { color: counterColor },
|
|
129
|
+
isAtLimit ? `🚫 ${MAX_CHARS}/${MAX_CHARS}` : `${charCount}/${MAX_CHARS}`
|
|
130
|
+
),
|
|
131
|
+
),
|
|
125
132
|
),
|
|
126
133
|
);
|
|
127
134
|
}
|
package/src/tui/ink-app.js
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
6
6
|
import { Box, Text, useApp, useInput, useStdin } from 'ink';
|
|
7
7
|
import {
|
|
8
|
-
StatusBar, MessageBubble, ThinkingIndicator,
|
|
8
|
+
AsciiBanner, StatusBar, MessageBubble, ThinkingIndicator, stripEmojis,
|
|
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,14 +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
|
-
|
|
36
|
-
|
|
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);
|
|
37
53
|
}, []);
|
|
38
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
|
+
|
|
39
62
|
// ─── Enable Ink mode for logger ──────────────────
|
|
40
63
|
useEffect(() => {
|
|
41
64
|
setInkMode(true, bus);
|
|
@@ -49,6 +72,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
49
72
|
useEffect(() => {
|
|
50
73
|
const onLlmText = (text) => {
|
|
51
74
|
setThinking('idle');
|
|
75
|
+
if (activityTimer.current) { clearTimeout(activityTimer.current); activityTimer.current = null; }
|
|
52
76
|
// Strip emojis to check if there's real content
|
|
53
77
|
const clean = text.replace(/\p{Emoji_Presentation}/gu, '').replace(/\p{Extended_Pictographic}/gu, '').trim();
|
|
54
78
|
if (!clean) return; // skip emoji-only LLM responses (TTS will carry the real text)
|
|
@@ -58,6 +82,8 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
58
82
|
};
|
|
59
83
|
|
|
60
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; }
|
|
61
87
|
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
62
88
|
id: `tool-${Date.now()}`, role: 'tool',
|
|
63
89
|
toolName: name, action: args?.action,
|
|
@@ -71,14 +97,164 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
71
97
|
}]);
|
|
72
98
|
};
|
|
73
99
|
|
|
74
|
-
|
|
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
|
+
};
|
|
75
134
|
const onReady = () => {
|
|
76
135
|
setConnected(true);
|
|
77
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;
|
|
78
141
|
};
|
|
79
|
-
const onDisconnect = () => {
|
|
142
|
+
const onDisconnect = (err) => {
|
|
80
143
|
setConnected(false);
|
|
81
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.`);
|
|
82
258
|
};
|
|
83
259
|
|
|
84
260
|
// Single log line that updates in place
|
|
@@ -99,6 +275,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
99
275
|
const onTtsSentence = (text) => {
|
|
100
276
|
ttsBuffer += text;
|
|
101
277
|
setThinking('idle');
|
|
278
|
+
if (activityTimer.current) { clearTimeout(activityTimer.current); activityTimer.current = null; }
|
|
102
279
|
// Debounce: wait for all TTS sentences to arrive, then display
|
|
103
280
|
if (ttsTimer) clearTimeout(ttsTimer);
|
|
104
281
|
ttsTimer = setTimeout(() => {
|
|
@@ -117,23 +294,84 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
117
294
|
}, 500);
|
|
118
295
|
};
|
|
119
296
|
|
|
297
|
+
// Agent progress events
|
|
298
|
+
const onAgentProgress = ({ state, message, tool, action }) => {
|
|
299
|
+
if (state === 'tool') {
|
|
300
|
+
agentBusyRef.current = true;
|
|
301
|
+
setThinking('tool_call');
|
|
302
|
+
setLogLine(`[TOOL] ${tool}.${action || ''}...`);
|
|
303
|
+
armWatchdog();
|
|
304
|
+
} else if (state === 'tool_done') {
|
|
305
|
+
agentBusyRef.current = true;
|
|
306
|
+
setThinking('thinking');
|
|
307
|
+
setLogLine(`[TOOL] done, waiting...`);
|
|
308
|
+
armWatchdog();
|
|
309
|
+
} else if (state === 'receiving') {
|
|
310
|
+
agentBusyRef.current = true;
|
|
311
|
+
setLogLine(`[LLM] ${message}`);
|
|
312
|
+
armWatchdog();
|
|
313
|
+
} else if (state === 'thinking') {
|
|
314
|
+
agentBusyRef.current = true;
|
|
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();
|
|
325
|
+
} else if (state === 'timeout') {
|
|
326
|
+
agentBusyRef.current = false;
|
|
327
|
+
setThinking('idle');
|
|
328
|
+
clearWatchdog();
|
|
329
|
+
setLogLine(`[WARN] ${message}`);
|
|
330
|
+
pushSystem(`timeout: ${message}`);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
120
334
|
bus.on('llm:text', onLlmText);
|
|
121
335
|
bus.on('tts:sentence', onTtsSentence);
|
|
122
336
|
bus.on('tool:called', onToolCalled);
|
|
337
|
+
bus.on('files:changed', onFilesChanged);
|
|
123
338
|
bus.on('thinking', onThinking);
|
|
124
339
|
bus.on('ready', onReady);
|
|
340
|
+
bus.on('session:end', onSessionEnd);
|
|
341
|
+
bus.on('session:dead', onSessionDead);
|
|
125
342
|
bus.on('mqtt:error', onDisconnect);
|
|
343
|
+
bus.on('mqtt:disconnected', onDisconnect);
|
|
344
|
+
bus.on('mqtt:reconnecting', onReconnecting);
|
|
345
|
+
bus.on('mqtt:reconnected', onReconnected);
|
|
126
346
|
bus.on('log:message', onLogMessage);
|
|
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);
|
|
127
352
|
|
|
128
353
|
return () => {
|
|
129
354
|
bus.off('llm:text', onLlmText);
|
|
130
355
|
bus.off('tts:sentence', onTtsSentence);
|
|
131
356
|
bus.off('tool:called', onToolCalled);
|
|
357
|
+
bus.off('files:changed', onFilesChanged);
|
|
132
358
|
bus.off('thinking', onThinking);
|
|
133
359
|
bus.off('ready', onReady);
|
|
360
|
+
bus.off('session:end', onSessionEnd);
|
|
361
|
+
bus.off('session:dead', onSessionDead);
|
|
134
362
|
bus.off('mqtt:error', onDisconnect);
|
|
363
|
+
bus.off('mqtt:disconnected', onDisconnect);
|
|
364
|
+
bus.off('mqtt:reconnecting', onReconnecting);
|
|
365
|
+
bus.off('mqtt:reconnected', onReconnected);
|
|
135
366
|
bus.off('log:message', onLogMessage);
|
|
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);
|
|
136
372
|
if (ttsTimer) clearTimeout(ttsTimer);
|
|
373
|
+
if (activityTimer.current) clearTimeout(activityTimer.current);
|
|
374
|
+
if (taskTimer.current) clearTimeout(taskTimer.current);
|
|
137
375
|
};
|
|
138
376
|
}, []);
|
|
139
377
|
|
|
@@ -171,6 +409,16 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
171
409
|
|
|
172
410
|
// ─── Handle Submit ────────────────────────────────
|
|
173
411
|
const handleSubmit = useCallback(async (text) => {
|
|
412
|
+
// Limit input to 70 chars (XiaoZhi limit)
|
|
413
|
+
const MAX_INPUT = 70;
|
|
414
|
+
if (text.length > MAX_INPUT) {
|
|
415
|
+
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
416
|
+
id: `warn-${Date.now()}`, role: 'system',
|
|
417
|
+
content: `⚠️ Input too long (${text.length}/${MAX_INPUT} chars)`,
|
|
418
|
+
}]);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
174
422
|
// Add user message
|
|
175
423
|
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
176
424
|
id: `user-${Date.now()}`, role: 'user', content: text,
|
|
@@ -200,6 +448,16 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
200
448
|
}]);
|
|
201
449
|
return;
|
|
202
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
|
+
}
|
|
203
461
|
if (cmd === '/tools') {
|
|
204
462
|
const tools = registry.list();
|
|
205
463
|
const lines = tools.map(t => ` ${t.name} (${t.actions?.length || 0})`).join('\n');
|
|
@@ -229,10 +487,23 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
229
487
|
const intent = parseIntent(text);
|
|
230
488
|
if (intent.matched) {
|
|
231
489
|
setThinking('tool_call');
|
|
232
|
-
|
|
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
|
+
);
|
|
233
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
|
+
}
|
|
234
505
|
const { formatToolResult } = await import('../core/formatter.js');
|
|
235
|
-
const formatted = formatToolResult(intent.tool, intent.action, result);
|
|
506
|
+
const formatted = formatToolResult(intent.tool, intent.action, dispatched.result);
|
|
236
507
|
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
237
508
|
id: `tool-${Date.now()}`, role: 'tool',
|
|
238
509
|
toolName: intent.tool, action: intent.action,
|
|
@@ -244,31 +515,99 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
244
515
|
}
|
|
245
516
|
} catch {}
|
|
246
517
|
|
|
247
|
-
// Send to XiaoZhi
|
|
518
|
+
// Send to XiaoZhi via Agent Controller (if available) or direct
|
|
248
519
|
setThinking('thinking');
|
|
249
|
-
|
|
520
|
+
|
|
521
|
+
const agentController = extras?.agentController;
|
|
522
|
+
if (agentController) {
|
|
523
|
+
// Reset agent if it's stuck in running state
|
|
524
|
+
if (agentController.isRunning) {
|
|
525
|
+
agentController.abort();
|
|
526
|
+
await new Promise(r => setTimeout(r, 500));
|
|
527
|
+
}
|
|
528
|
+
|
|
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.
|
|
534
|
+
try {
|
|
535
|
+
await agentController.run(text, { generatePlan: false });
|
|
536
|
+
setThinking('idle');
|
|
537
|
+
} catch (err) {
|
|
538
|
+
setThinking('idle');
|
|
539
|
+
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
540
|
+
id: `error-${Date.now()}`, role: 'system', content: `Error: ${err.message}`,
|
|
541
|
+
}]);
|
|
542
|
+
}
|
|
543
|
+
} else {
|
|
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
|
+
}
|
|
553
|
+
}
|
|
250
554
|
}, [registry, mqtt, stats, session]);
|
|
251
555
|
|
|
252
556
|
// ─── Render ───────────────────────────────────────
|
|
253
557
|
const tools = registry.all();
|
|
254
558
|
const actions = tools.reduce((s, t) => s + (t.actions?.length || 0), 0);
|
|
255
559
|
|
|
256
|
-
//
|
|
257
|
-
|
|
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)
|
|
258
572
|
const totalMessages = messages.length;
|
|
259
573
|
const endIdx = totalMessages - scrollOffset;
|
|
260
|
-
|
|
574
|
+
|
|
575
|
+
// Count lines backwards from endIdx to fit within MAX_RENDER_LINES
|
|
576
|
+
let usedLines = 0;
|
|
577
|
+
let startIdx = endIdx;
|
|
578
|
+
for (let i = endIdx - 1; i >= 0; i--) {
|
|
579
|
+
const msg = messages[i];
|
|
580
|
+
let msgLines = 1;
|
|
581
|
+
if (msg.role === 'assistant' && msg.content) {
|
|
582
|
+
const w = (process.stdout.columns || 80) - 6;
|
|
583
|
+
const words = stripEmojis(msg.content).split(/\s+/);
|
|
584
|
+
let lineLen = 0;
|
|
585
|
+
msgLines = 1;
|
|
586
|
+
for (const word of words) {
|
|
587
|
+
if (lineLen + word.length + 1 > w && lineLen > 0) { msgLines++; lineLen = word.length; }
|
|
588
|
+
else { lineLen += word.length + (lineLen > 0 ? 1 : 0); }
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (usedLines + msgLines > MAX_RENDER_LINES) break;
|
|
592
|
+
usedLines += msgLines;
|
|
593
|
+
startIdx = i;
|
|
594
|
+
}
|
|
595
|
+
|
|
261
596
|
const visibleMessages = messages.slice(startIdx, endIdx);
|
|
262
|
-
const canScrollUp =
|
|
597
|
+
const canScrollUp = startIdx > 0;
|
|
263
598
|
const canScrollDown = scrollOffset > 0;
|
|
264
599
|
|
|
265
600
|
return React.createElement(Box, {
|
|
266
601
|
flexDirection: 'column', width: '100%',
|
|
267
602
|
},
|
|
268
|
-
// Top: Status bar
|
|
603
|
+
// Top: Banner + Status bar (always visible / pinned)
|
|
604
|
+
React.createElement(AsciiBanner, {
|
|
605
|
+
key: 'ascii-banner',
|
|
606
|
+
version: config?.agent?.version,
|
|
607
|
+
}),
|
|
269
608
|
React.createElement(StatusBar, {
|
|
609
|
+
key: 'status-bar',
|
|
270
610
|
connected, session: sessionId,
|
|
271
|
-
version: config?.agent?.version,
|
|
272
611
|
}),
|
|
273
612
|
|
|
274
613
|
// Log status line (single line, auto-updating, auto-hiding)
|
|
@@ -282,7 +621,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
|
|
|
282
621
|
canScrollUp
|
|
283
622
|
? React.createElement(Box, { paddingLeft: 2 },
|
|
284
623
|
React.createElement(Text, { dimColor: true, color: 'yellow' },
|
|
285
|
-
`[${
|
|
624
|
+
`[${startIdx} more above | Shift+Up/Down or PgUp/PgDn to scroll]`
|
|
286
625
|
),
|
|
287
626
|
)
|
|
288
627
|
: null,
|