pelulu-cli 1.0.0 → 1.0.3
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/README.md +69 -82
- package/config.example.json +2 -1
- package/package.json +6 -3
- package/preview.jpg +0 -0
- package/src/core/auto-format.js +61 -12
- package/src/core/config.js +1 -1
- package/src/core/confirm.js +2 -2
- package/src/core/diff.js +4 -4
- package/src/core/doctor.js +2 -2
- package/src/core/error-handler.js +1 -1
- package/src/core/file-tracker.js +4 -4
- package/src/core/formatter.js +19 -19
- package/src/core/logger.js +19 -3
- package/src/core/spinner.js +2 -2
- package/src/core/stats.js +2 -2
- package/src/core/thinking.js +8 -8
- package/src/core/tool-help.js +2 -2
- package/src/core/tool-registry.js +6 -10
- package/src/core/update-checker.js +125 -0
- package/src/core/wizard.js +5 -5
- package/src/core/workspace.js +4 -4
- package/src/index.js +89 -37
- package/src/mcp/activation.js +2 -5
- package/src/mcp/message-sender.js +1 -1
- package/src/mcp/mqtt-client.js +4 -4
- package/src/repl-commands.js +2 -1
- package/src/repl.js +26 -6
- package/src/tools/config.js +1 -1
- package/src/tools/diff.js +6 -0
- package/src/tools/file.js +3 -3
- package/src/tools/git.js +1 -1
- package/src/tools/network.js +1 -1
- package/src/tools/project.js +2 -2
- package/src/tools/search.js +3 -3
- package/src/tools/shell.js +2 -2
- package/src/tools/template.js +1 -1
- package/src/tools/watch.js +1 -1
- package/src/tui/completable-input.js +127 -0
- package/src/tui/ink-app.js +324 -0
- package/src/tui/ink-components.js +157 -0
- package/src/tui/ink-entry.js +86 -0
- package/src/tui/renderer.js +137 -19
- package/src/tui/status-bar.js +7 -7
package/src/tools/diff.js
CHANGED
|
@@ -6,6 +6,7 @@ import { readFile } from 'fs/promises';
|
|
|
6
6
|
import { resolve } from 'path';
|
|
7
7
|
import { homedir } from 'os';
|
|
8
8
|
import { log } from '../core/logger.js';
|
|
9
|
+
import { showDiff } from '../core/diff.js';
|
|
9
10
|
|
|
10
11
|
const HOME = homedir();
|
|
11
12
|
|
|
@@ -50,6 +51,11 @@ const ACTIONS = {
|
|
|
50
51
|
|
|
51
52
|
return { file1: abs1, file2: abs2, totalChanges: changes.length, hunks: hunks.slice(0, 20) };
|
|
52
53
|
},
|
|
54
|
+
format: async ({ file1, file2 }) => {
|
|
55
|
+
const c1 = await readFile(file1, 'utf-8').catch(() => '');
|
|
56
|
+
const c2 = await readFile(file2, 'utf-8').catch(() => '');
|
|
57
|
+
if (c1 && c2) showDiff(c1, c2, file1);
|
|
58
|
+
},
|
|
53
59
|
},
|
|
54
60
|
|
|
55
61
|
stats: {
|
package/src/tools/file.js
CHANGED
|
@@ -40,7 +40,7 @@ const ACTIONS = {
|
|
|
40
40
|
const abs = safe(path);
|
|
41
41
|
await mkdir(dirname(abs), { recursive: true });
|
|
42
42
|
await writeFile(abs, content, 'utf-8');
|
|
43
|
-
log('file',
|
|
43
|
+
log('file', `[EDIT] Written: ${abs} (${content.length} chars)`);
|
|
44
44
|
return { path: abs, written: content.length };
|
|
45
45
|
},
|
|
46
46
|
},
|
|
@@ -54,7 +54,7 @@ const ACTIONS = {
|
|
|
54
54
|
const count = content.split(old_text).length - 1;
|
|
55
55
|
content = content.replace(old_text, new_text);
|
|
56
56
|
await writeFile(abs, content, 'utf-8');
|
|
57
|
-
log('file',
|
|
57
|
+
log('file', `[EDIT] Edited: ${abs} (${count} occurrence(s))`);
|
|
58
58
|
return { path: abs, edited: true, occurrences: count };
|
|
59
59
|
},
|
|
60
60
|
},
|
|
@@ -80,7 +80,7 @@ const ACTIONS = {
|
|
|
80
80
|
const abs = safe(path);
|
|
81
81
|
if (!existsSync(abs)) throw new Error(`Not found: ${abs}`);
|
|
82
82
|
await unlink(abs);
|
|
83
|
-
log('file',
|
|
83
|
+
log('file', `[DEL] Deleted: ${abs}`);
|
|
84
84
|
return { path: abs, deleted: true };
|
|
85
85
|
},
|
|
86
86
|
},
|
package/src/tools/git.js
CHANGED
|
@@ -85,7 +85,7 @@ const ACTIONS = {
|
|
|
85
85
|
const msg = params.message.replace(/"/g, '\\"');
|
|
86
86
|
const r = await git(`commit -m "${msg}"`, dir);
|
|
87
87
|
if (r.code !== 0 && r.stderr.includes('nothing to commit')) return { committed: false, reason: 'nothing to commit' };
|
|
88
|
-
log('git',
|
|
88
|
+
log('git', `[EDIT] Committed: ${params.message}`);
|
|
89
89
|
return { committed: true, message: params.message, output: r.stdout.slice(-300) };
|
|
90
90
|
},
|
|
91
91
|
},
|
package/src/tools/network.js
CHANGED
|
@@ -25,7 +25,7 @@ function httpGet(url, maxChars = 5000) {
|
|
|
25
25
|
const ACTIONS = {
|
|
26
26
|
async fetch({ url, method, body, headers }) {
|
|
27
27
|
if (!url) throw new Error('url required');
|
|
28
|
-
log('network',
|
|
28
|
+
log('network', `[WEB] ${method || 'GET'} ${url}`);
|
|
29
29
|
const result = await httpGet(url);
|
|
30
30
|
return { url, status: result.status, body: result.body.slice(0, 5000) };
|
|
31
31
|
},
|
package/src/tools/project.js
CHANGED
|
@@ -57,7 +57,7 @@ const ACTIONS = {
|
|
|
57
57
|
const type = await detectProject(dir);
|
|
58
58
|
const cmd = BUILD_CMD[type];
|
|
59
59
|
if (!cmd) throw new Error(`No build for ${type}`);
|
|
60
|
-
log('project',
|
|
60
|
+
log('project', `[BUILD] Building (${type})...`);
|
|
61
61
|
const r = await run(cmd, dir);
|
|
62
62
|
return { success: r.code === 0, type, exitCode: r.code, output: r.stdout.slice(-500) || r.stderr.slice(-500) };
|
|
63
63
|
},
|
|
@@ -70,7 +70,7 @@ const ACTIONS = {
|
|
|
70
70
|
const type = await detectProject(dir);
|
|
71
71
|
const cmd = TEST_CMD[type];
|
|
72
72
|
if (!cmd) throw new Error(`No test for ${type}`);
|
|
73
|
-
log('project',
|
|
73
|
+
log('project', `[TEST] Testing (${type})...`);
|
|
74
74
|
const r = await run(cmd, dir);
|
|
75
75
|
return { passed: r.code === 0, type, exitCode: r.code, output: r.stdout.slice(-1000) || r.stderr.slice(-1000) };
|
|
76
76
|
},
|
package/src/tools/search.js
CHANGED
|
@@ -34,7 +34,7 @@ const ACTIONS = {
|
|
|
34
34
|
required: ['pattern'],
|
|
35
35
|
handler: async ({ pattern, path, ignoreCase, include, context }) => {
|
|
36
36
|
if (!pattern) throw new Error('pattern required');
|
|
37
|
-
log('search',
|
|
37
|
+
log('search', `[FIND] grep: ${pattern}`);
|
|
38
38
|
const flags = ignoreCase ? '-rn' : '-rn';
|
|
39
39
|
const ic = ignoreCase ? 'i' : '';
|
|
40
40
|
const inc = include ? `--include="*.${include}"` : '';
|
|
@@ -49,7 +49,7 @@ const ACTIONS = {
|
|
|
49
49
|
find: {
|
|
50
50
|
required: ['name'],
|
|
51
51
|
handler: async ({ name, path, type }) => {
|
|
52
|
-
log('search',
|
|
52
|
+
log('search', `[FIND] find: ${name}`);
|
|
53
53
|
const t = type ? `-type ${type}` : '';
|
|
54
54
|
const cmd = `find "${path || '.'}" ${t} -name "${name}" 2>/dev/null | head -50`;
|
|
55
55
|
const r = await runCmd(cmd);
|
|
@@ -60,7 +60,7 @@ const ACTIONS = {
|
|
|
60
60
|
web: {
|
|
61
61
|
required: ['url'],
|
|
62
62
|
handler: async ({ url, maxChars }) => {
|
|
63
|
-
log('search',
|
|
63
|
+
log('search', `[WEB] fetch: ${url}`);
|
|
64
64
|
const r = await httpGet(url, maxChars || 8000);
|
|
65
65
|
return { url, status: r.status, body: r.body.slice(0, maxChars || 8000), length: r.body.length };
|
|
66
66
|
},
|
package/src/tools/shell.js
CHANGED
|
@@ -26,7 +26,7 @@ const ACTIONS = {
|
|
|
26
26
|
exec: {
|
|
27
27
|
required: ['command'],
|
|
28
28
|
handler: async ({ command, timeout }) => {
|
|
29
|
-
log('shell',
|
|
29
|
+
log('shell', `[CFG] $ ${command}`);
|
|
30
30
|
const result = await run(command, timeout);
|
|
31
31
|
const maxLen = getConfig().tools?.max_output || 10000;
|
|
32
32
|
return { stdout: result.stdout.slice(0, maxLen), stderr: result.stderr.slice(0, 2000), exitCode: result.code };
|
|
@@ -36,7 +36,7 @@ const ACTIONS = {
|
|
|
36
36
|
bg: {
|
|
37
37
|
required: ['command'],
|
|
38
38
|
handler: async ({ command }) => {
|
|
39
|
-
log('shell',
|
|
39
|
+
log('shell', `[CFG] [bg] $ ${command}`);
|
|
40
40
|
const child = spawn('bash', ['-c', command], { detached: true, stdio: 'ignore' });
|
|
41
41
|
child.unref();
|
|
42
42
|
return { pid: child.pid, background: true, command };
|
package/src/tools/template.js
CHANGED
|
@@ -71,7 +71,7 @@ const ACTIONS = {
|
|
|
71
71
|
for (const [file, content] of Object.entries(files)) {
|
|
72
72
|
await writeFile(join(dir, file), content);
|
|
73
73
|
}
|
|
74
|
-
log('template',
|
|
74
|
+
log('template', `[DIR] Created ${template}: ${dir}`);
|
|
75
75
|
return { created: true, template, path: dir, files: Object.keys(files) };
|
|
76
76
|
},
|
|
77
77
|
},
|
package/src/tools/watch.js
CHANGED
|
@@ -26,7 +26,7 @@ const ACTIONS = {
|
|
|
26
26
|
const watcher = fsWatch(abs, { recursive: recursive !== false }, (eventType, filename) => {
|
|
27
27
|
if (filter && !filename.includes(filter)) return;
|
|
28
28
|
const event = { type: eventType, file: filename, path: abs, ts: Date.now() };
|
|
29
|
-
log('watch',
|
|
29
|
+
log('watch', `[DIR] ${eventType}: ${filename}`);
|
|
30
30
|
bus.emit('file:change', event);
|
|
31
31
|
});
|
|
32
32
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CompletableInput — TextInput with Tab auto-completion
|
|
3
|
+
* Wraps ink-text-input and adds completion support
|
|
4
|
+
*/
|
|
5
|
+
import React, { useState, useCallback } from 'react';
|
|
6
|
+
import { Box, Text, useInput } from 'ink';
|
|
7
|
+
import TextInput from 'ink-text-input';
|
|
8
|
+
import { getCompletions } from '../core/completion.js';
|
|
9
|
+
|
|
10
|
+
export function CompletableInput({ onSubmit, placeholder }) {
|
|
11
|
+
const [value, setValue] = useState('');
|
|
12
|
+
const [completions, setCompletions] = useState([]);
|
|
13
|
+
const [completionIndex, setCompletionIndex] = useState(0);
|
|
14
|
+
const [showCompletions, setShowCompletions] = useState(false);
|
|
15
|
+
|
|
16
|
+
// Handle Tab for completion
|
|
17
|
+
useInput((input, key) => {
|
|
18
|
+
if (key.tab) {
|
|
19
|
+
if (!showCompletions) {
|
|
20
|
+
// First Tab: show completions
|
|
21
|
+
const hits = getCompletions(value);
|
|
22
|
+
if (hits.length > 0) {
|
|
23
|
+
setCompletions(hits);
|
|
24
|
+
setCompletionIndex(0);
|
|
25
|
+
setShowCompletions(true);
|
|
26
|
+
// Auto-fill first match
|
|
27
|
+
if (hits.length === 1) {
|
|
28
|
+
setValue(hits[0] + ' ');
|
|
29
|
+
setShowCompletions(false);
|
|
30
|
+
} else {
|
|
31
|
+
setValue(hits[0]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
// Subsequent Tab: cycle through completions
|
|
36
|
+
const next = (completionIndex + 1) % completions.length;
|
|
37
|
+
setCompletionIndex(next);
|
|
38
|
+
setValue(completions[next]);
|
|
39
|
+
}
|
|
40
|
+
} else if (key.escape) {
|
|
41
|
+
// Escape: hide completions
|
|
42
|
+
setShowCompletions(false);
|
|
43
|
+
setCompletions([]);
|
|
44
|
+
setCompletionIndex(0);
|
|
45
|
+
} else if (key.return) {
|
|
46
|
+
// Enter: handled by TextInput onSubmit, do nothing here
|
|
47
|
+
setShowCompletions(false);
|
|
48
|
+
setCompletions([]);
|
|
49
|
+
} else {
|
|
50
|
+
// Any other key: reset completions
|
|
51
|
+
if (showCompletions) {
|
|
52
|
+
setShowCompletions(false);
|
|
53
|
+
setCompletions([]);
|
|
54
|
+
setCompletionIndex(0);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Update value from TextInput (but not on Tab/Enter which are handled above)
|
|
60
|
+
const handleChange = useCallback((val) => {
|
|
61
|
+
setValue(val);
|
|
62
|
+
// Live completion hints
|
|
63
|
+
if (val.length > 0) {
|
|
64
|
+
const hits = getCompletions(val);
|
|
65
|
+
if (hits.length > 0 && hits[0] !== val) {
|
|
66
|
+
setCompletions(hits);
|
|
67
|
+
setShowCompletions(true);
|
|
68
|
+
setCompletionIndex(0);
|
|
69
|
+
} else {
|
|
70
|
+
setShowCompletions(false);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
setShowCompletions(false);
|
|
74
|
+
}
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
// Handle TextInput submit (Enter key)
|
|
78
|
+
const handleSubmit = useCallback((val) => {
|
|
79
|
+
const trimmed = val.trim();
|
|
80
|
+
if (trimmed) {
|
|
81
|
+
onSubmit(trimmed);
|
|
82
|
+
setValue('');
|
|
83
|
+
}
|
|
84
|
+
setShowCompletions(false);
|
|
85
|
+
setCompletions([]);
|
|
86
|
+
}, [onSubmit]);
|
|
87
|
+
|
|
88
|
+
return React.createElement(Box, { flexDirection: 'column', width: '100%' },
|
|
89
|
+
// Completion suggestions
|
|
90
|
+
showCompletions && completions.length > 1
|
|
91
|
+
? React.createElement(Box, {
|
|
92
|
+
paddingLeft: 2, paddingBottom: 0,
|
|
93
|
+
flexDirection: 'row', flexWrap: 'wrap', gap: 1,
|
|
94
|
+
},
|
|
95
|
+
completions.slice(0, 8).map((c, i) =>
|
|
96
|
+
React.createElement(Text, {
|
|
97
|
+
key: c,
|
|
98
|
+
color: i === completionIndex ? 'cyan' : 'dim',
|
|
99
|
+
bold: i === completionIndex,
|
|
100
|
+
dimColor: i !== completionIndex,
|
|
101
|
+
},
|
|
102
|
+
i === completionIndex ? `[${c}]` : ` ${c} `
|
|
103
|
+
)
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
: null,
|
|
107
|
+
|
|
108
|
+
// Input line
|
|
109
|
+
React.createElement(Box, { paddingX: 1 },
|
|
110
|
+
React.createElement(Text, { color: 'cyan' }, '> '),
|
|
111
|
+
React.createElement(TextInput, {
|
|
112
|
+
value,
|
|
113
|
+
onChange: handleChange,
|
|
114
|
+
onSubmit: handleSubmit,
|
|
115
|
+
placeholder: placeholder || 'type a message...',
|
|
116
|
+
// Don't show cursor when we have a completion suggestion
|
|
117
|
+
showCursor: !showCompletions || completions.length <= 1,
|
|
118
|
+
}),
|
|
119
|
+
// Show ghost completion (the remaining part of the suggested completion)
|
|
120
|
+
showCompletions && completions.length > 0 && value.length > 0
|
|
121
|
+
? React.createElement(Text, { dimColor: true },
|
|
122
|
+
completions[completionIndex]?.slice(value.length) || ''
|
|
123
|
+
)
|
|
124
|
+
: null,
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ink App — Main Pelulu TUI Application
|
|
3
|
+
* Full React component with message list, input, status, thinking
|
|
4
|
+
*/
|
|
5
|
+
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
6
|
+
import { Box, Text, useApp, useInput, useStdin } from 'ink';
|
|
7
|
+
import {
|
|
8
|
+
StatusBar, MessageBubble, ThinkingIndicator,
|
|
9
|
+
} from './ink-components.js';
|
|
10
|
+
import { CompletableInput } from './completable-input.js';
|
|
11
|
+
import { setInkMode } from '../core/logger.js';
|
|
12
|
+
|
|
13
|
+
export function createApp({ registry, mqtt, stats, session, bus, config, extras }) {
|
|
14
|
+
return function App() {
|
|
15
|
+
const { exit } = useApp();
|
|
16
|
+
const { isRawModeSupported } = useStdin();
|
|
17
|
+
const [messages, setMessages] = useState([]);
|
|
18
|
+
const [thinking, setThinking] = useState('idle');
|
|
19
|
+
const [connected, setConnected] = useState(mqtt.connected);
|
|
20
|
+
const [sessionId, setSessionId] = useState(mqtt.sessionId);
|
|
21
|
+
// Show last startup log as initial log line
|
|
22
|
+
const _startupLogs = extras?.startupLogs || [];
|
|
23
|
+
const _lastStartup = _startupLogs.length
|
|
24
|
+
? _startupLogs[_startupLogs.length - 1].replace(/\x1b\[[0-9;]*m/g, '')
|
|
25
|
+
: '';
|
|
26
|
+
const [logLine, setLogLine] = useState(_lastStartup);
|
|
27
|
+
const logTimer = useRef(null);
|
|
28
|
+
const maxMessages = 200;
|
|
29
|
+
const [scrollOffset, setScrollOffset] = useState(0); // 0 = bottom, N = scrolled up N messages
|
|
30
|
+
const scrollOffsetRef = useRef(0);
|
|
31
|
+
|
|
32
|
+
// Calculate how many rows are available for messages
|
|
33
|
+
const getAvailableRows = useCallback(() => {
|
|
34
|
+
const termRows = process.stdout.rows || 24;
|
|
35
|
+
// status bar (3) + log line (1) + input box (3) + padding (2) + thinking (1) = ~10
|
|
36
|
+
return Math.max(5, termRows - 10);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
// ─── Enable Ink mode for logger ──────────────────
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
setInkMode(true, bus);
|
|
42
|
+
return () => {
|
|
43
|
+
setInkMode(false, null);
|
|
44
|
+
if (logTimer.current) clearTimeout(logTimer.current);
|
|
45
|
+
};
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
// ─── Bus Events ───────────────────────────────────
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const onLlmText = (text) => {
|
|
51
|
+
setThinking('idle');
|
|
52
|
+
// Strip emojis to check if there's real content
|
|
53
|
+
const clean = text.replace(/\p{Emoji_Presentation}/gu, '').replace(/\p{Extended_Pictographic}/gu, '').trim();
|
|
54
|
+
if (!clean) return; // skip emoji-only LLM responses (TTS will carry the real text)
|
|
55
|
+
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
56
|
+
id: Date.now().toString(), role: 'assistant', content: text,
|
|
57
|
+
}]);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const onToolCalled = ({ name, result, args }) => {
|
|
61
|
+
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
62
|
+
id: `tool-${Date.now()}`, role: 'tool',
|
|
63
|
+
toolName: name, action: args?.action,
|
|
64
|
+
detail: args?.path || args?.command || args?.pattern || '',
|
|
65
|
+
}, {
|
|
66
|
+
id: `result-${Date.now()}`, role: 'tool',
|
|
67
|
+
toolName: '', action: '',
|
|
68
|
+
detail: result.isError
|
|
69
|
+
? `x ${result.content?.[0]?.text || 'error'}`
|
|
70
|
+
: 'v OK',
|
|
71
|
+
}]);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const onThinking = ({ state }) => setThinking(state);
|
|
75
|
+
const onReady = () => {
|
|
76
|
+
setConnected(true);
|
|
77
|
+
setSessionId(mqtt.sessionId);
|
|
78
|
+
};
|
|
79
|
+
const onDisconnect = () => {
|
|
80
|
+
setConnected(false);
|
|
81
|
+
setSessionId(null);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Single log line that updates in place
|
|
85
|
+
const onLogMessage = ({ level, msg }) => {
|
|
86
|
+
const LABELS = { ok: 'OK', err: 'ERR', warn: 'WARN', info: 'i', tool: 'TOOL', mcp: 'MCP', user: 'USER', ai: 'AI' };
|
|
87
|
+
const label = LABELS[level] || level.toUpperCase();
|
|
88
|
+
setLogLine(`[${label}] ${msg}`);
|
|
89
|
+
// Auto-hide after 4s of no new logs
|
|
90
|
+
if (logTimer.current) clearTimeout(logTimer.current);
|
|
91
|
+
logTimer.current = setTimeout(() => setLogLine(''), 4000);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// XiaoZhi sends responses as TTS sentences, not LLM text
|
|
95
|
+
// Accumulate TTS sentences and display as assistant message
|
|
96
|
+
let ttsBuffer = '';
|
|
97
|
+
let ttsTimer = null;
|
|
98
|
+
|
|
99
|
+
const onTtsSentence = (text) => {
|
|
100
|
+
ttsBuffer += text;
|
|
101
|
+
setThinking('idle');
|
|
102
|
+
// Debounce: wait for all TTS sentences to arrive, then display
|
|
103
|
+
if (ttsTimer) clearTimeout(ttsTimer);
|
|
104
|
+
ttsTimer = setTimeout(() => {
|
|
105
|
+
if (ttsBuffer) {
|
|
106
|
+
setMessages(prev => {
|
|
107
|
+
const next = [...prev.slice(-maxMessages), {
|
|
108
|
+
id: Date.now().toString(), role: 'assistant', content: ttsBuffer,
|
|
109
|
+
}];
|
|
110
|
+
// Auto-scroll to bottom on new AI response
|
|
111
|
+
setScrollOffset(0);
|
|
112
|
+
scrollOffsetRef.current = 0;
|
|
113
|
+
return next;
|
|
114
|
+
});
|
|
115
|
+
ttsBuffer = '';
|
|
116
|
+
}
|
|
117
|
+
}, 500);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
bus.on('llm:text', onLlmText);
|
|
121
|
+
bus.on('tts:sentence', onTtsSentence);
|
|
122
|
+
bus.on('tool:called', onToolCalled);
|
|
123
|
+
bus.on('thinking', onThinking);
|
|
124
|
+
bus.on('ready', onReady);
|
|
125
|
+
bus.on('mqtt:error', onDisconnect);
|
|
126
|
+
bus.on('log:message', onLogMessage);
|
|
127
|
+
|
|
128
|
+
return () => {
|
|
129
|
+
bus.off('llm:text', onLlmText);
|
|
130
|
+
bus.off('tts:sentence', onTtsSentence);
|
|
131
|
+
bus.off('tool:called', onToolCalled);
|
|
132
|
+
bus.off('thinking', onThinking);
|
|
133
|
+
bus.off('ready', onReady);
|
|
134
|
+
bus.off('mqtt:error', onDisconnect);
|
|
135
|
+
bus.off('log:message', onLogMessage);
|
|
136
|
+
if (ttsTimer) clearTimeout(ttsTimer);
|
|
137
|
+
};
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
// ─── Keyboard shortcuts ─────────────────────────
|
|
141
|
+
useInput((input, key) => {
|
|
142
|
+
if (key.ctrl && input === 'c') exit();
|
|
143
|
+
|
|
144
|
+
// Scroll: Shift+Up / Shift+Down / PageUp / PageDown
|
|
145
|
+
if (key.shift && key.upArrow) {
|
|
146
|
+
setScrollOffset(prev => {
|
|
147
|
+
const next = Math.min(prev + 5, messages.length - 1);
|
|
148
|
+
scrollOffsetRef.current = next;
|
|
149
|
+
return next;
|
|
150
|
+
});
|
|
151
|
+
} else if (key.shift && key.downArrow) {
|
|
152
|
+
setScrollOffset(prev => {
|
|
153
|
+
const next = Math.max(0, prev - 5);
|
|
154
|
+
scrollOffsetRef.current = next;
|
|
155
|
+
return next;
|
|
156
|
+
});
|
|
157
|
+
} else if (key.pageUp) {
|
|
158
|
+
setScrollOffset(prev => {
|
|
159
|
+
const next = Math.min(prev + 20, messages.length - 1);
|
|
160
|
+
scrollOffsetRef.current = next;
|
|
161
|
+
return next;
|
|
162
|
+
});
|
|
163
|
+
} else if (key.pageDown) {
|
|
164
|
+
setScrollOffset(prev => {
|
|
165
|
+
const next = Math.max(0, prev - 20);
|
|
166
|
+
scrollOffsetRef.current = next;
|
|
167
|
+
return next;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ─── Handle Submit ────────────────────────────────
|
|
173
|
+
const handleSubmit = useCallback(async (text) => {
|
|
174
|
+
// Add user message
|
|
175
|
+
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
176
|
+
id: `user-${Date.now()}`, role: 'user', content: text,
|
|
177
|
+
}]);
|
|
178
|
+
|
|
179
|
+
// Slash commands
|
|
180
|
+
if (text.startsWith('/')) {
|
|
181
|
+
const [cmd, ...rest] = text.split(' ');
|
|
182
|
+
const arg = rest.join(' ');
|
|
183
|
+
const ctx = { registry, mqtt, stats, session, fileTracker: extras.fileTracker, history: [] };
|
|
184
|
+
|
|
185
|
+
if (cmd === '/quit' || cmd === '/exit') {
|
|
186
|
+
exit();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (cmd === '/clear') {
|
|
190
|
+
setMessages([]);
|
|
191
|
+
setScrollOffset(0);
|
|
192
|
+
scrollOffsetRef.current = 0;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (cmd === '/status') {
|
|
196
|
+
const s = session.getStats();
|
|
197
|
+
setMessages(prev => [...prev, {
|
|
198
|
+
id: `sys-${Date.now()}`, role: 'system',
|
|
199
|
+
content: `mqtt: ${mqtt.connected ? 'on' : 'off'} | session: ${mqtt.sessionId || '-'} | tools: ${registry.all().length} | turns: ${s.turns} | calls: ${s.toolCalls}`,
|
|
200
|
+
}]);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (cmd === '/tools') {
|
|
204
|
+
const tools = registry.list();
|
|
205
|
+
const lines = tools.map(t => ` ${t.name} (${t.actions?.length || 0})`).join('\n');
|
|
206
|
+
setMessages(prev => [...prev, {
|
|
207
|
+
id: `sys-${Date.now()}`, role: 'system', content: `tools:\n${lines}`,
|
|
208
|
+
}]);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Try repl-commands handler
|
|
213
|
+
try {
|
|
214
|
+
const { handleCommand } = await import('../repl-commands.js');
|
|
215
|
+
const special = await handleCommand(cmd, arg, ctx);
|
|
216
|
+
if (special === 'help') {
|
|
217
|
+
setMessages(prev => [...prev, {
|
|
218
|
+
id: `sys-${Date.now()}`, role: 'system',
|
|
219
|
+
content: '/tools /status /stats /clear /quit /call <tool> <action> /keys',
|
|
220
|
+
}]);
|
|
221
|
+
}
|
|
222
|
+
} catch {}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Intent parsing (natural language shortcuts)
|
|
227
|
+
try {
|
|
228
|
+
const { parseIntent } = await import('../core/intent.js');
|
|
229
|
+
const intent = parseIntent(text);
|
|
230
|
+
if (intent.matched) {
|
|
231
|
+
setThinking('tool_call');
|
|
232
|
+
const result = await registry.call(intent.tool, intent.params);
|
|
233
|
+
setThinking('idle');
|
|
234
|
+
const { formatToolResult } = await import('../core/formatter.js');
|
|
235
|
+
const formatted = formatToolResult(intent.tool, intent.action, result);
|
|
236
|
+
setMessages(prev => [...prev.slice(-maxMessages), {
|
|
237
|
+
id: `tool-${Date.now()}`, role: 'tool',
|
|
238
|
+
toolName: intent.tool, action: intent.action,
|
|
239
|
+
detail: '',
|
|
240
|
+
}, {
|
|
241
|
+
id: `result-${Date.now()}`, role: 'assistant', content: formatted,
|
|
242
|
+
}]);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
} catch {}
|
|
246
|
+
|
|
247
|
+
// Send to XiaoZhi
|
|
248
|
+
setThinking('thinking');
|
|
249
|
+
mqtt.sendText(text);
|
|
250
|
+
}, [registry, mqtt, stats, session]);
|
|
251
|
+
|
|
252
|
+
// ─── Render ───────────────────────────────────────
|
|
253
|
+
const tools = registry.all();
|
|
254
|
+
const actions = tools.reduce((s, t) => s + (t.actions?.length || 0), 0);
|
|
255
|
+
|
|
256
|
+
// Calculate visible messages (scrollable window)
|
|
257
|
+
const availableRows = getAvailableRows();
|
|
258
|
+
const totalMessages = messages.length;
|
|
259
|
+
const endIdx = totalMessages - scrollOffset;
|
|
260
|
+
const startIdx = Math.max(0, endIdx - availableRows);
|
|
261
|
+
const visibleMessages = messages.slice(startIdx, endIdx);
|
|
262
|
+
const canScrollUp = scrollOffset < totalMessages - 1;
|
|
263
|
+
const canScrollDown = scrollOffset > 0;
|
|
264
|
+
|
|
265
|
+
return React.createElement(Box, {
|
|
266
|
+
flexDirection: 'column', width: '100%',
|
|
267
|
+
},
|
|
268
|
+
// Top: Status bar
|
|
269
|
+
React.createElement(StatusBar, {
|
|
270
|
+
connected, session: sessionId,
|
|
271
|
+
version: config?.agent?.version,
|
|
272
|
+
}),
|
|
273
|
+
|
|
274
|
+
// Log status line (single line, auto-updating, auto-hiding)
|
|
275
|
+
logLine
|
|
276
|
+
? React.createElement(Box, { paddingLeft: 1 },
|
|
277
|
+
React.createElement(Text, { dimColor: true, color: 'gray' }, logLine),
|
|
278
|
+
)
|
|
279
|
+
: null,
|
|
280
|
+
|
|
281
|
+
// Scroll indicator (only when scrolled up)
|
|
282
|
+
canScrollUp
|
|
283
|
+
? React.createElement(Box, { paddingLeft: 2 },
|
|
284
|
+
React.createElement(Text, { dimColor: true, color: 'yellow' },
|
|
285
|
+
`[${totalMessages - endIdx} more above | Shift+Up/Down or PgUp/PgDn to scroll]`
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
: null,
|
|
289
|
+
|
|
290
|
+
// Middle: Messages (scrollable window)
|
|
291
|
+
React.createElement(Box, {
|
|
292
|
+
flexDirection: 'column', paddingY: 1,
|
|
293
|
+
},
|
|
294
|
+
totalMessages === 0
|
|
295
|
+
? React.createElement(Box, { paddingLeft: 2 },
|
|
296
|
+
React.createElement(Text, { dimColor: true },
|
|
297
|
+
'type a message to get started. tab for autocomplete. /help for commands.'
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
: visibleMessages.map((msg) =>
|
|
301
|
+
React.createElement(MessageBubble, { key: msg.id, message: msg })
|
|
302
|
+
),
|
|
303
|
+
canScrollDown
|
|
304
|
+
? React.createElement(Box, { paddingLeft: 2 },
|
|
305
|
+
React.createElement(Text, { dimColor: true, color: 'yellow' },
|
|
306
|
+
`[${scrollOffset} more below | Shift+Down or PgDn to scroll to bottom]`
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
: null,
|
|
310
|
+
React.createElement(ThinkingIndicator, { state: thinking }),
|
|
311
|
+
),
|
|
312
|
+
|
|
313
|
+
// Bottom: Input
|
|
314
|
+
React.createElement(Box, {
|
|
315
|
+
borderStyle: 'single', borderColor: 'cyan', paddingX: 0,
|
|
316
|
+
},
|
|
317
|
+
React.createElement(CompletableInput, {
|
|
318
|
+
onSubmit: handleSubmit,
|
|
319
|
+
placeholder: 'type a message or /help ...',
|
|
320
|
+
}),
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
};
|
|
324
|
+
}
|