pelulu-cli 1.0.0 → 1.0.1

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 (49) hide show
  1. package/DailyNotesApp/app/build.gradle.kts +57 -0
  2. package/DailyNotesApp/app/src/main/java/com/dailynotes/app/MainActivity.kt +48 -0
  3. package/DailyNotesApp/app/src/main/java/com/dailynotes/app/Note.kt +13 -0
  4. package/DailyNotesApp/app/src/main/java/com/dailynotes/app/NoteDatabase.kt +9 -0
  5. package/DailyNotesApp/build.gradle.kts +5 -0
  6. package/DailyNotesApp/settings.gradle.kts +17 -0
  7. package/README.md +69 -82
  8. package/config.example.json +2 -1
  9. package/package.json +6 -3
  10. package/preview.jpg +0 -0
  11. package/src/core/auto-format.js +61 -12
  12. package/src/core/config.js +1 -1
  13. package/src/core/confirm.js +2 -2
  14. package/src/core/diff.js +4 -4
  15. package/src/core/doctor.js +2 -2
  16. package/src/core/error-handler.js +1 -1
  17. package/src/core/file-tracker.js +4 -4
  18. package/src/core/formatter.js +19 -19
  19. package/src/core/logger.js +19 -3
  20. package/src/core/spinner.js +2 -2
  21. package/src/core/stats.js +2 -2
  22. package/src/core/thinking.js +8 -8
  23. package/src/core/tool-help.js +2 -2
  24. package/src/core/tool-registry.js +6 -10
  25. package/src/core/update-checker.js +125 -0
  26. package/src/core/wizard.js +5 -5
  27. package/src/core/workspace.js +4 -4
  28. package/src/index.js +82 -37
  29. package/src/mcp/activation.js +3 -3
  30. package/src/mcp/message-sender.js +1 -1
  31. package/src/mcp/mqtt-client.js +4 -4
  32. package/src/repl-commands.js +2 -1
  33. package/src/repl.js +26 -6
  34. package/src/tools/config.js +1 -1
  35. package/src/tools/diff.js +6 -0
  36. package/src/tools/file.js +3 -3
  37. package/src/tools/git.js +1 -1
  38. package/src/tools/network.js +1 -1
  39. package/src/tools/project.js +2 -2
  40. package/src/tools/search.js +3 -3
  41. package/src/tools/shell.js +2 -2
  42. package/src/tools/template.js +1 -1
  43. package/src/tools/watch.js +1 -1
  44. package/src/tui/completable-input.js +127 -0
  45. package/src/tui/ink-app.js +324 -0
  46. package/src/tui/ink-components.js +157 -0
  47. package/src/tui/ink-entry.js +86 -0
  48. package/src/tui/renderer.js +137 -19
  49. package/src/tui/status-bar.js +7 -7
@@ -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
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Ink UI Components — building blocks for Pelulu TUI
3
+ * Uses React.createElement (no JSX — ESM compatible, no build step)
4
+ */
5
+ import React, { useState, useEffect, useRef } from 'react';
6
+ import { Box, Text, useStdin } from 'ink';
7
+ import TextInput from 'ink-text-input';
8
+
9
+ // ─── Status Bar ───────────────────────────────────────────
10
+ export function StatusBar({ connected, session, version }) {
11
+ return React.createElement(Box, {
12
+ borderStyle: 'single', borderColor: 'cyan', width: '100%', paddingX: 0,
13
+ },
14
+ React.createElement(Text, { color: 'cyan', bold: true }, ` pelulu-cli v.${version || '0.0.0'} `),
15
+ React.createElement(Text, { dimColor: true }, '| '),
16
+ React.createElement(Text, { color: connected ? 'green' : 'red', bold: connected },
17
+ connected ? 'ONLINE' : 'OFFLINE'
18
+ ),
19
+ React.createElement(Text, { dimColor: true }, ' | '),
20
+ React.createElement(Text, { dimColor: true }, session ? session.slice(0, 8) : '-'),
21
+ React.createElement(Text, { dimColor: true }, ' | Provider: Xiaozhi.me'),
22
+ );
23
+ }
24
+
25
+ // ─── Strip Emojis ─────────────────────────────────────────
26
+ function stripEmojis(text) {
27
+ return text
28
+ .replace(/\p{Emoji_Presentation}/gu, '')
29
+ .replace(/\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?/gu, '')
30
+ .replace(/\p{Extended_Pictographic}/gu, '')
31
+ .replace(/[\u2600-\u27BF]/g, '')
32
+ .replace(/[\u{1F000}-\u{1FFFF}]/gu, '')
33
+ .replace(/[\u200D\uFE0F]/g, '')
34
+ .replace(/[ \t]{2,}/g, ' ')
35
+ .replace(/ ([.,;:!?])/g, '$1')
36
+ .trim();
37
+ }
38
+
39
+ // ─── Wrap text helper ─────────────────────────────────────
40
+ function wrapText(text, maxWidth) {
41
+ const lines = [];
42
+ for (const paragraph of text.split('\n')) {
43
+ if (!paragraph.trim()) { lines.push(''); continue; }
44
+ const words = paragraph.split(/\s+/);
45
+ let line = '';
46
+ for (const word of words) {
47
+ if (line.length + word.length + 1 > maxWidth && line.length > 0) {
48
+ lines.push(line);
49
+ line = word;
50
+ } else {
51
+ line = line ? `${line} ${word}` : word;
52
+ }
53
+ }
54
+ if (line) lines.push(line);
55
+ }
56
+ return lines;
57
+ }
58
+
59
+ // ─── Message Bubble ───────────────────────────────────────
60
+ export function MessageBubble({ message }) {
61
+ const { role, content } = message;
62
+ const isUser = role === 'user';
63
+ const isTool = role === 'tool';
64
+ const isSystem = role === 'system';
65
+
66
+ if (isTool) {
67
+ return React.createElement(Box, { paddingLeft: 2 },
68
+ React.createElement(Text, { dimColor: true },
69
+ `${message.toolName || 'tool'}${message.action ? '.' + message.action : ''} ${message.detail || ''}`
70
+ ),
71
+ );
72
+ }
73
+
74
+ if (isSystem) {
75
+ return React.createElement(Box, { paddingLeft: 2 },
76
+ React.createElement(Text, { dimColor: true, italic: true }, content),
77
+ );
78
+ }
79
+
80
+ const cleanContent = stripEmojis(content);
81
+ if (!cleanContent) return null;
82
+
83
+ const color = isUser ? 'blue' : 'white';
84
+ const w = (process.stdout.columns || 80) - 6;
85
+
86
+ if (isUser) {
87
+ return React.createElement(Box, { paddingLeft: 1, flexDirection: 'column' },
88
+ React.createElement(Text, { color, bold: true }, `> ${cleanContent}`),
89
+ );
90
+ }
91
+
92
+ // Assistant: wrap + indent all lines
93
+ const lines = wrapText(cleanContent, w);
94
+ return React.createElement(Box, { paddingLeft: 2, flexDirection: 'column' },
95
+ ...lines.map((line, i) =>
96
+ React.createElement(Text, { key: i, color }, line)
97
+ ),
98
+ );
99
+ }
100
+
101
+ // ─── Tool Result Line ─────────────────────────────────────
102
+ export function ToolResultLine({ success, detail }) {
103
+ return React.createElement(Box, { paddingLeft: 4 },
104
+ React.createElement(Text, { color: success ? 'green' : 'red' },
105
+ success ? `v ${detail || 'OK'}` : `x ${detail || 'error'}`
106
+ ),
107
+ );
108
+ }
109
+
110
+ // ─── Input Bar ────────────────────────────────────────────
111
+ export function InputBar({ onSubmit, placeholder }) {
112
+ const [value, setValue] = useState('');
113
+
114
+ const handleSubmit = (val) => {
115
+ const trimmed = val.trim();
116
+ if (!trimmed) return;
117
+ onSubmit(trimmed);
118
+ setValue('');
119
+ };
120
+
121
+ return React.createElement(Box, { paddingX: 1 },
122
+ React.createElement(Text, { color: 'cyan' }, '> '),
123
+ React.createElement(TextInput, {
124
+ value,
125
+ onChange: setValue,
126
+ onSubmit: handleSubmit,
127
+ placeholder: placeholder || 'type a message...',
128
+ }),
129
+ );
130
+ }
131
+
132
+ // ─── Thinking Indicator ───────────────────────────────────
133
+ export function ThinkingIndicator({ state }) {
134
+ if (!state || state === 'idle') return null;
135
+
136
+ const labels = {
137
+ thinking: 'thinking...',
138
+ tool_call: 'using tool...',
139
+ reading: 'reading file...',
140
+ writing: 'writing...',
141
+ searching: 'searching...',
142
+ };
143
+
144
+ return React.createElement(Box, { paddingLeft: 2 },
145
+ React.createElement(Text, { dimColor: true, italic: true },
146
+ `.. ${labels[state] || 'processing...'}`
147
+ ),
148
+ );
149
+ }
150
+
151
+ // ─── Divider ──────────────────────────────────────────────
152
+ export function Divider() {
153
+ return React.createElement(Box, { width: '100%' },
154
+ React.createElement(Text, { dimColor: true }, '─'.repeat(process.stdout.columns || 80)),
155
+ );
156
+ }
157
+
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Ink Entry — renders the Ink TUI and connects to Pelulu internals
3
+ * Falls back to readline REPL if Ink can't start (non-TTY, etc.)
4
+ */
5
+ import React from 'react';
6
+ import { render } from 'ink';
7
+ import { createApp } from './ink-app.js';
8
+
9
+ export function startInkTUI({ registry, mqtt, stats, session, bus, config, extras }) {
10
+ const isTTY = process.stdin.isTTY;
11
+
12
+ if (!isTTY) {
13
+ return startFallbackREPL({ registry, mqtt, stats, session, bus, config, extras });
14
+ }
15
+
16
+ const App = createApp({ registry, mqtt, stats, session, bus, config, extras });
17
+
18
+ const { unmount, waitUntilExit } = render(
19
+ React.createElement(App),
20
+ { exitOnCtrlC: false },
21
+ );
22
+ return { unmount, waitUntilExit };
23
+ }
24
+
25
+ async function startFallbackREPL({ registry, mqtt, stats, session, bus, extras }) {
26
+ const { createInterface } = await import('readline');
27
+ const chalk = (await import('chalk')).default;
28
+ const { getCompletions } = await import('../core/completion.js');
29
+
30
+ console.log(chalk.gray('\n Non-interactive mode. Type to chat.\n'));
31
+
32
+ const rl = createInterface({
33
+ input: process.stdin, output: process.stdout,
34
+ prompt: chalk.cyan('> '),
35
+ completer: (line) => {
36
+ const hits = getCompletions(line);
37
+ return [hits.length ? hits : [], line];
38
+ },
39
+ });
40
+
41
+ bus.on('llm:text', (text) => {
42
+ const clean = text.replace(/\p{Emoji_Presentation}/gu, '').replace(/\p{Extended_Pictographic}/gu, '').trim();
43
+ if (clean) console.log(chalk.white(` ${clean}`));
44
+ rl.prompt();
45
+ });
46
+
47
+ bus.on('tool:called', ({ name, result, args }) => {
48
+ const status = result.isError ? chalk.red('x') : chalk.green('v');
49
+ const detail = args?.path || args?.command || '';
50
+ console.log(chalk.dim(` ${name}.${args?.action || ''} ${detail} ${status}`));
51
+ });
52
+
53
+ bus.on('ready', () => rl.prompt());
54
+
55
+ rl.on('line', async (input) => {
56
+ const text = input.trim();
57
+ if (!text) return rl.prompt();
58
+
59
+ if (text.startsWith('/quit') || text.startsWith('/exit')) {
60
+ process.exit(0);
61
+ }
62
+
63
+ // Intent parsing
64
+ try {
65
+ const { parseIntent } = await import('../core/intent.js');
66
+ const intent = parseIntent(text);
67
+ if (intent.matched) {
68
+ const result = await registry.call(intent.tool, intent.params);
69
+ const { formatToolResult } = await import('../core/formatter.js');
70
+ console.log(`\n${formatToolResult(intent.tool, intent.action, result)}\n`);
71
+ return rl.prompt();
72
+ }
73
+ } catch {}
74
+
75
+ mqtt.sendText(text);
76
+ rl.prompt();
77
+ });
78
+
79
+ rl.on('close', () => process.exit(0));
80
+ rl.prompt();
81
+
82
+ return {
83
+ unmount: () => rl.close(),
84
+ waitUntilExit: () => new Promise(() => {}),
85
+ };
86
+ }