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.
- package/DailyNotesApp/app/build.gradle.kts +57 -0
- package/DailyNotesApp/app/src/main/java/com/dailynotes/app/MainActivity.kt +48 -0
- package/DailyNotesApp/app/src/main/java/com/dailynotes/app/Note.kt +13 -0
- package/DailyNotesApp/app/src/main/java/com/dailynotes/app/NoteDatabase.kt +9 -0
- package/DailyNotesApp/build.gradle.kts +5 -0
- package/DailyNotesApp/settings.gradle.kts +17 -0
- 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 +82 -37
- package/src/mcp/activation.js +3 -3
- 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
|
@@ -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
|
+
}
|