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.
Files changed (43) hide show
  1. package/README.md +69 -82
  2. package/config.example.json +2 -1
  3. package/package.json +6 -3
  4. package/preview.jpg +0 -0
  5. package/src/core/auto-format.js +61 -12
  6. package/src/core/config.js +1 -1
  7. package/src/core/confirm.js +2 -2
  8. package/src/core/diff.js +4 -4
  9. package/src/core/doctor.js +2 -2
  10. package/src/core/error-handler.js +1 -1
  11. package/src/core/file-tracker.js +4 -4
  12. package/src/core/formatter.js +19 -19
  13. package/src/core/logger.js +19 -3
  14. package/src/core/spinner.js +2 -2
  15. package/src/core/stats.js +2 -2
  16. package/src/core/thinking.js +8 -8
  17. package/src/core/tool-help.js +2 -2
  18. package/src/core/tool-registry.js +6 -10
  19. package/src/core/update-checker.js +125 -0
  20. package/src/core/wizard.js +5 -5
  21. package/src/core/workspace.js +4 -4
  22. package/src/index.js +89 -37
  23. package/src/mcp/activation.js +2 -5
  24. package/src/mcp/message-sender.js +1 -1
  25. package/src/mcp/mqtt-client.js +4 -4
  26. package/src/repl-commands.js +2 -1
  27. package/src/repl.js +26 -6
  28. package/src/tools/config.js +1 -1
  29. package/src/tools/diff.js +6 -0
  30. package/src/tools/file.js +3 -3
  31. package/src/tools/git.js +1 -1
  32. package/src/tools/network.js +1 -1
  33. package/src/tools/project.js +2 -2
  34. package/src/tools/search.js +3 -3
  35. package/src/tools/shell.js +2 -2
  36. package/src/tools/template.js +1 -1
  37. package/src/tools/watch.js +1 -1
  38. package/src/tui/completable-input.js +127 -0
  39. package/src/tui/ink-app.js +324 -0
  40. package/src/tui/ink-components.js +157 -0
  41. package/src/tui/ink-entry.js +86 -0
  42. package/src/tui/renderer.js +137 -19
  43. 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', `📝 Written: ${abs} (${content.length} chars)`);
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', `✏️ Edited: ${abs} (${count} occurrence(s))`);
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', `🗑️ Deleted: ${abs}`);
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', `📝 Committed: ${params.message}`);
88
+ log('git', `[EDIT] Committed: ${params.message}`);
89
89
  return { committed: true, message: params.message, output: r.stdout.slice(-300) };
90
90
  },
91
91
  },
@@ -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', `🌐 ${method || 'GET'} ${url}`);
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
  },
@@ -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', `🔨 Building (${type})...`);
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', `🧪 Testing (${type})...`);
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
  },
@@ -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', `🔍 grep: ${pattern}`);
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', `🔍 find: ${name}`);
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', `🌐 fetch: ${url}`);
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
  },
@@ -26,7 +26,7 @@ const ACTIONS = {
26
26
  exec: {
27
27
  required: ['command'],
28
28
  handler: async ({ command, timeout }) => {
29
- log('shell', `⚙️ $ ${command}`);
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', `⚙️ [bg] $ ${command}`);
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 };
@@ -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', `📁 Created ${template}: ${dir}`);
74
+ log('template', `[DIR] Created ${template}: ${dir}`);
75
75
  return { created: true, template, path: dir, files: Object.keys(files) };
76
76
  },
77
77
  },
@@ -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', `📁 ${eventType}: ${filename}`);
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
+ }