pelulu-cli 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Agent Tool — Expose agent capabilities (1 MCP tool, 5 actions)
3
+ * Actions: run, status, abort, reset, context
4
+ */
5
+ import { bus } from '../core/event-bus.js';
6
+
7
+ let agentController = null;
8
+
9
+ export function setAgentController(controller) {
10
+ agentController = controller;
11
+ }
12
+
13
+ const ACTIONS = {
14
+ run: {
15
+ required: ['task'],
16
+ handler: async ({ task }) => {
17
+ if (!agentController) throw new Error('Agent not initialized');
18
+ const result = await agentController.run(task, { generatePlan: false });
19
+ return { success: result.success, result: result.result, iterations: result.iterations };
20
+ },
21
+ },
22
+
23
+ status: {
24
+ required: [],
25
+ handler: async () => {
26
+ if (!agentController) throw new Error('Agent not initialized');
27
+ return agentController.summary;
28
+ },
29
+ },
30
+
31
+ abort: {
32
+ required: [],
33
+ handler: async () => {
34
+ if (!agentController) throw new Error('Agent not initialized');
35
+ agentController.abort();
36
+ return { aborted: true };
37
+ },
38
+ },
39
+
40
+ reset: {
41
+ required: [],
42
+ handler: async () => {
43
+ if (!agentController) throw new Error('Agent not initialized');
44
+ agentController.reset();
45
+ return { reset: true };
46
+ },
47
+ },
48
+
49
+ context: {
50
+ required: [],
51
+ handler: async () => {
52
+ if (!agentController) throw new Error('Agent not initialized');
53
+ return { context: await agentController.getContext() };
54
+ },
55
+ },
56
+ };
57
+
58
+ const actionNames = Object.keys(ACTIONS);
59
+
60
+ export default {
61
+ name: 'agent',
62
+ description: 'Agent: run task, status, abort, reset, context',
63
+ actions: actionNames.map(name => ({ name, required: ACTIONS[name].required })),
64
+ inputSchema: {
65
+ type: 'object',
66
+ properties: {
67
+ action: { type: 'string' },
68
+ task: { type: 'string' },
69
+ },
70
+ required: ['action'],
71
+ },
72
+ async handler({ action, ...params }) {
73
+ const a = ACTIONS[action];
74
+ if (!a) throw new Error(`Unknown: ${action}. Use: ${actionNames.join(', ')}`);
75
+ for (const f of a.required) {
76
+ if (params[f] === undefined) throw new Error(`Missing: ${f}`);
77
+ }
78
+ return a.handler(params);
79
+ },
80
+ };
@@ -1,68 +1,71 @@
1
1
  /**
2
- * CompletableInput — TextInput with Tab auto-completion
2
+ * CompletableInput — TextInput with Tab auto-completion + char counter
3
3
  * Wraps ink-text-input and adds completion support
4
4
  */
5
- import React, { useState, useCallback } from 'react';
5
+ import React, { useState, useCallback, useEffect } from 'react';
6
6
  import { Box, Text, useInput } from 'ink';
7
7
  import TextInput from 'ink-text-input';
8
8
  import { getCompletions } from '../core/completion.js';
9
9
 
10
+ const MAX_CHARS = 70;
11
+
10
12
  export function CompletableInput({ onSubmit, placeholder }) {
11
13
  const [value, setValue] = useState('');
12
14
  const [completions, setCompletions] = useState([]);
13
15
  const [completionIndex, setCompletionIndex] = useState(0);
14
16
  const [showCompletions, setShowCompletions] = useState(false);
15
17
 
16
- // Handle Tab for completion
18
+ const isAtLimit = value.length >= MAX_CHARS;
19
+ const charCount = value.length;
20
+ const remaining = MAX_CHARS - charCount;
21
+
22
+ // Handle Tab/Escape/Return — regular chars are handled by TextInput
17
23
  useInput((input, key) => {
18
24
  if (key.tab) {
19
25
  if (!showCompletions) {
20
- // First Tab: show completions
21
26
  const hits = getCompletions(value);
22
27
  if (hits.length > 0) {
23
28
  setCompletions(hits);
24
29
  setCompletionIndex(0);
25
30
  setShowCompletions(true);
26
- // Auto-fill first match
27
31
  if (hits.length === 1) {
28
- setValue(hits[0] + ' ');
32
+ const filled = hits[0] + ' ';
33
+ if (filled.length <= MAX_CHARS) {
34
+ setValue(filled);
35
+ }
29
36
  setShowCompletions(false);
30
37
  } else {
31
- setValue(hits[0]);
38
+ setValue(hits[0].slice(0, MAX_CHARS));
32
39
  }
33
40
  }
34
41
  } else {
35
- // Subsequent Tab: cycle through completions
36
42
  const next = (completionIndex + 1) % completions.length;
37
43
  setCompletionIndex(next);
38
- setValue(completions[next]);
44
+ setValue(completions[next].slice(0, MAX_CHARS));
39
45
  }
40
46
  } else if (key.escape) {
41
- // Escape: hide completions
42
47
  setShowCompletions(false);
43
48
  setCompletions([]);
44
49
  setCompletionIndex(0);
45
50
  } else if (key.return) {
46
- // Enter: handled by TextInput onSubmit, do nothing here
47
51
  setShowCompletions(false);
48
52
  setCompletions([]);
49
- } else {
50
- // Any other key: reset completions
51
- if (showCompletions) {
52
- setShowCompletions(false);
53
- setCompletions([]);
54
- setCompletionIndex(0);
55
- }
56
53
  }
54
+ // All other keys: let TextInput handle them (no-op here)
57
55
  });
58
56
 
59
- // Update value from TextInput (but not on Tab/Enter which are handled above)
57
+ // Update value from TextInput stable reference, no deps
60
58
  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) {
59
+ const trimmed = val.slice(0, MAX_CHARS);
60
+ setValue(trimmed);
61
+ }, []);
62
+
63
+ // Derive completions from value (debounced via useEffect to avoid
64
+ // extra re-renders on every character)
65
+ useEffect(() => {
66
+ if (value.length > 0) {
67
+ const hits = getCompletions(value);
68
+ if (hits.length > 0 && hits[0] !== value) {
66
69
  setCompletions(hits);
67
70
  setShowCompletions(true);
68
71
  setCompletionIndex(0);
@@ -72,9 +75,9 @@ export function CompletableInput({ onSubmit, placeholder }) {
72
75
  } else {
73
76
  setShowCompletions(false);
74
77
  }
75
- }, []);
78
+ }, [value]);
76
79
 
77
- // Handle TextInput submit (Enter key)
80
+ // Handle TextInput submit
78
81
  const handleSubmit = useCallback((val) => {
79
82
  const trimmed = val.trim();
80
83
  if (trimmed) {
@@ -85,6 +88,9 @@ export function CompletableInput({ onSubmit, placeholder }) {
85
88
  setCompletions([]);
86
89
  }, [onSubmit]);
87
90
 
91
+ // Color for char counter
92
+ const counterColor = isAtLimit ? 'red' : remaining <= 10 ? 'yellow' : 'dim';
93
+
88
94
  return React.createElement(Box, { flexDirection: 'column', width: '100%' },
89
95
  // Completion suggestions
90
96
  showCompletions && completions.length > 1
@@ -105,23 +111,24 @@ export function CompletableInput({ onSubmit, placeholder }) {
105
111
  )
106
112
  : null,
107
113
 
108
- // Input line
109
- React.createElement(Box, { paddingX: 1 },
114
+ // Input line with char counter
115
+ React.createElement(Box, { paddingX: 1, flexDirection: 'row', alignItems: 'center' },
110
116
  React.createElement(Text, { color: 'cyan' }, '> '),
111
- React.createElement(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,
117
+ React.createElement(Box, { flexDirection: 'column', flexGrow: 1 },
118
+ React.createElement(TextInput, {
119
+ value,
120
+ onChange: handleChange,
121
+ onSubmit: handleSubmit,
122
+ placeholder: isAtLimit ? '⚠️ limit reached!' : (placeholder || 'type a message...'),
123
+ showCursor: !showCompletions || completions.length <= 1,
124
+ }),
125
+ ),
126
+ // Char counter
127
+ React.createElement(Box, { marginLeft: 1 },
128
+ React.createElement(Text, { color: counterColor },
129
+ isAtLimit ? `🚫 ${MAX_CHARS}/${MAX_CHARS}` : `${charCount}/${MAX_CHARS}`
130
+ ),
131
+ ),
125
132
  ),
126
133
  );
127
134
  }
@@ -5,7 +5,7 @@
5
5
  import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
6
6
  import { Box, Text, useApp, useInput, useStdin } from 'ink';
7
7
  import {
8
- StatusBar, MessageBubble, ThinkingIndicator,
8
+ AsciiBanner, StatusBar, MessageBubble, ThinkingIndicator, stripEmojis,
9
9
  } from './ink-components.js';
10
10
  import { CompletableInput } from './completable-input.js';
11
11
  import { setInkMode } from '../core/logger.js';
@@ -30,10 +30,9 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
30
30
  const scrollOffsetRef = useRef(0);
31
31
 
32
32
  // Calculate how many rows are available for messages
33
+ const MAX_VISIBLE_MESSAGES = 12;
33
34
  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);
35
+ return MAX_VISIBLE_MESSAGES;
37
36
  }, []);
38
37
 
39
38
  // ─── Enable Ink mode for logger ──────────────────
@@ -117,6 +116,23 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
117
116
  }, 500);
118
117
  };
119
118
 
119
+ // Agent progress events
120
+ const onAgentProgress = ({ state, message, tool, action }) => {
121
+ if (state === 'tool') {
122
+ setThinking('tool_call');
123
+ setLogLine(`[TOOL] ${tool}.${action || ''}...`);
124
+ } else if (state === 'tool_done') {
125
+ setThinking('thinking');
126
+ setLogLine(`[TOOL] done, waiting...`);
127
+ } else if (state === 'receiving') {
128
+ setLogLine(`[LLM] ${message}`);
129
+ } else if (state === 'thinking') {
130
+ setThinking('thinking');
131
+ } else if (state === 'timeout') {
132
+ setLogLine(`[WARN] ${message}`);
133
+ }
134
+ };
135
+
120
136
  bus.on('llm:text', onLlmText);
121
137
  bus.on('tts:sentence', onTtsSentence);
122
138
  bus.on('tool:called', onToolCalled);
@@ -124,6 +140,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
124
140
  bus.on('ready', onReady);
125
141
  bus.on('mqtt:error', onDisconnect);
126
142
  bus.on('log:message', onLogMessage);
143
+ bus.on('agent:progress', onAgentProgress);
127
144
 
128
145
  return () => {
129
146
  bus.off('llm:text', onLlmText);
@@ -133,6 +150,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
133
150
  bus.off('ready', onReady);
134
151
  bus.off('mqtt:error', onDisconnect);
135
152
  bus.off('log:message', onLogMessage);
153
+ bus.off('agent:progress', onAgentProgress);
136
154
  if (ttsTimer) clearTimeout(ttsTimer);
137
155
  };
138
156
  }, []);
@@ -171,6 +189,16 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
171
189
 
172
190
  // ─── Handle Submit ────────────────────────────────
173
191
  const handleSubmit = useCallback(async (text) => {
192
+ // Limit input to 70 chars (XiaoZhi limit)
193
+ const MAX_INPUT = 70;
194
+ if (text.length > MAX_INPUT) {
195
+ setMessages(prev => [...prev.slice(-maxMessages), {
196
+ id: `warn-${Date.now()}`, role: 'system',
197
+ content: `⚠️ Input too long (${text.length}/${MAX_INPUT} chars)`,
198
+ }]);
199
+ return;
200
+ }
201
+
174
202
  // Add user message
175
203
  setMessages(prev => [...prev.slice(-maxMessages), {
176
204
  id: `user-${Date.now()}`, role: 'user', content: text,
@@ -244,31 +272,82 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
244
272
  }
245
273
  } catch {}
246
274
 
247
- // Send to XiaoZhi
275
+ // Send to XiaoZhi via Agent Controller (if available) or direct
248
276
  setThinking('thinking');
249
- mqtt.sendText(text);
277
+
278
+ const agentController = extras?.agentController;
279
+ if (agentController) {
280
+ // Reset agent if it's stuck in running state
281
+ if (agentController.isRunning) {
282
+ agentController.abort();
283
+ await new Promise(r => setTimeout(r, 500));
284
+ }
285
+
286
+ // Use agent controller for proper response handling
287
+ try {
288
+ const result = await agentController.run(text, { generatePlan: false });
289
+ setThinking('idle');
290
+
291
+ if (result.success && result.result) {
292
+ setMessages(prev => [...prev.slice(-maxMessages), {
293
+ id: `assistant-${Date.now()}`, role: 'assistant', content: result.result,
294
+ }]);
295
+ }
296
+ } catch (err) {
297
+ setThinking('idle');
298
+ setMessages(prev => [...prev.slice(-maxMessages), {
299
+ id: `error-${Date.now()}`, role: 'system', content: `Error: ${err.message}`,
300
+ }]);
301
+ }
302
+ } else {
303
+ // Fallback: send directly to XiaoZhi
304
+ mqtt.sendText(text);
305
+ }
250
306
  }, [registry, mqtt, stats, session]);
251
307
 
252
308
  // ─── Render ───────────────────────────────────────
253
309
  const tools = registry.all();
254
310
  const actions = tools.reduce((s, t) => s + (t.actions?.length || 0), 0);
255
311
 
256
- // Calculate visible messages (scrollable window)
257
- const availableRows = getAvailableRows();
312
+ // Calculate visible messages (scrollable window, max 12 rendered lines)
313
+ const MAX_RENDER_LINES = 12;
258
314
  const totalMessages = messages.length;
259
315
  const endIdx = totalMessages - scrollOffset;
260
- const startIdx = Math.max(0, endIdx - availableRows);
316
+
317
+ // Count lines backwards from endIdx to fit within MAX_RENDER_LINES
318
+ let usedLines = 0;
319
+ let startIdx = endIdx;
320
+ for (let i = endIdx - 1; i >= 0; i--) {
321
+ const msg = messages[i];
322
+ let msgLines = 1;
323
+ if (msg.role === 'assistant' && msg.content) {
324
+ const w = (process.stdout.columns || 80) - 6;
325
+ const words = stripEmojis(msg.content).split(/\s+/);
326
+ let lineLen = 0;
327
+ msgLines = 1;
328
+ for (const word of words) {
329
+ if (lineLen + word.length + 1 > w && lineLen > 0) { msgLines++; lineLen = word.length; }
330
+ else { lineLen += word.length + (lineLen > 0 ? 1 : 0); }
331
+ }
332
+ }
333
+ if (usedLines + msgLines > MAX_RENDER_LINES) break;
334
+ usedLines += msgLines;
335
+ startIdx = i;
336
+ }
337
+
261
338
  const visibleMessages = messages.slice(startIdx, endIdx);
262
- const canScrollUp = scrollOffset < totalMessages - 1;
339
+ const canScrollUp = startIdx > 0;
263
340
  const canScrollDown = scrollOffset > 0;
264
341
 
265
342
  return React.createElement(Box, {
266
343
  flexDirection: 'column', width: '100%',
267
344
  },
268
- // Top: Status bar
345
+ // Top: Banner + Status bar
346
+ React.createElement(AsciiBanner, {
347
+ version: config?.agent?.version,
348
+ }),
269
349
  React.createElement(StatusBar, {
270
350
  connected, session: sessionId,
271
- version: config?.agent?.version,
272
351
  }),
273
352
 
274
353
  // Log status line (single line, auto-updating, auto-hiding)
@@ -5,25 +5,72 @@
5
5
  import React, { useState, useEffect, useRef } from 'react';
6
6
  import { Box, Text, useStdin } from 'ink';
7
7
  import TextInput from 'ink-text-input';
8
+ import { readFile } from 'fs/promises';
9
+ import { join, dirname } from 'path';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const ROOT = join(__dirname, '..', '..');
14
+
15
+ let _pkgVersion = null;
16
+ async function readPkgVersion() {
17
+ if (_pkgVersion) return _pkgVersion;
18
+ try {
19
+ const pkg = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf-8'));
20
+ _pkgVersion = pkg.version || '0.0.0';
21
+ } catch { _pkgVersion = '0.0.0'; }
22
+ return _pkgVersion;
23
+ }
24
+ readPkgVersion();
25
+
26
+ // ─── ASCII Banner ────────────────────────────────────────
27
+ // Memoized — only re-renders when version prop changes
28
+ export const AsciiBanner = React.memo(function AsciiBanner({ version }) {
29
+ const v = version || '0.0.0';
30
+ return React.createElement(Box, {
31
+ flexDirection: 'column', paddingLeft: 1,
32
+ },
33
+ React.createElement(Box, null,
34
+ React.createElement(Text, { color: 'cyan' }, ' /\\_/\\ '),
35
+ React.createElement(Text, { color: 'cyan', bold: true }, 'PELULU-CLI'),
36
+ ),
37
+ React.createElement(Text, { color: 'cyan' }, ' ( o.o ) v' + v),
38
+ React.createElement(Text, { color: 'cyan' }, ' > ^ < the tiny coding agent'),
39
+ React.createElement(Text, { color: 'cyan' }, ' /| |\\ powered by XiaoZhi'),
40
+ React.createElement(Text, { color: 'cyan' }, '(_| |_)'),
41
+ React.createElement(Text, { dimColor: true }, ' 18 tools • MCP protocol • agent mode'),
42
+ );
43
+ });
8
44
 
9
45
  // ─── Status Bar ───────────────────────────────────────────
10
- export function StatusBar({ connected, session, version }) {
46
+ // Memoized only re-renders when connected/session changes
47
+ export const StatusBar = React.memo(function StatusBar({ connected, session }) {
48
+ const statusDot = connected ? '●' : '○';
49
+ const statusColor = connected ? 'green' : 'red';
50
+ const sess = session ? session.slice(0, 8) : '---';
51
+
11
52
  return React.createElement(Box, {
12
- borderStyle: 'single', borderColor: 'cyan', width: '100%', paddingX: 0,
53
+ borderStyle: 'single', borderColor: 'cyan', width: '100%',
54
+ paddingX: 1, paddingY: 0,
55
+ flexDirection: 'row', justifyContent: 'space-between',
13
56
  },
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'
57
+ React.createElement(Box, { flexDirection: 'row' },
58
+ React.createElement(Text, { color: 'cyan', bold: true }, '🐱 PELULU '),
59
+ React.createElement(Text, { color: statusColor }, statusDot),
60
+ React.createElement(Text, { color: statusColor, bold: connected },
61
+ connected ? ' online' : ' offline'
62
+ ),
63
+ ),
64
+ React.createElement(Box, { flexDirection: 'row' },
65
+ React.createElement(Text, { dimColor: true }, `session:${sess}`),
66
+ React.createElement(Text, { dimColor: true }, ' '),
67
+ React.createElement(Text, { dimColor: true }, 'xiaozhi.me'),
18
68
  ),
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
69
  );
23
- }
70
+ });
24
71
 
25
72
  // ─── Strip Emojis ─────────────────────────────────────────
26
- function stripEmojis(text) {
73
+ export function stripEmojis(text) {
27
74
  return text
28
75
  .replace(/\p{Emoji_Presentation}/gu, '')
29
76
  .replace(/\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?/gu, '')
@@ -10,7 +10,15 @@ export function startInkTUI({ registry, mqtt, stats, session, bus, config, extra
10
10
  const isTTY = process.stdin.isTTY;
11
11
 
12
12
  if (!isTTY) {
13
- return startFallbackREPL({ registry, mqtt, stats, session, bus, config, extras });
13
+ // Return a wrapper that handles the async REPL
14
+ let replResult = null;
15
+ const replPromise = startFallbackREPL({ registry, mqtt, stats, session, bus, config, extras })
16
+ .then(result => { replResult = result; return result; });
17
+
18
+ return {
19
+ unmount: () => { if (replResult) replResult.unmount(); },
20
+ waitUntilExit: () => replPromise,
21
+ };
14
22
  }
15
23
 
16
24
  const App = createApp({ registry, mqtt, stats, session, bus, config, extras });
@@ -72,7 +80,23 @@ async function startFallbackREPL({ registry, mqtt, stats, session, bus, extras }
72
80
  }
73
81
  } catch {}
74
82
 
75
- mqtt.sendText(text);
83
+ // Use agent controller if available, otherwise send directly
84
+ const agentController = extras?.agentController;
85
+ if (agentController) {
86
+ try {
87
+ const result = await agentController.run(text, { generatePlan: false });
88
+ if (result.success && result.result) {
89
+ const clean = result.result.replace(/\p{Emoji_Presentation}/gu, '').replace(/\p{Extended_Pictographic}/gu, '').trim();
90
+ if (clean) console.log(chalk.white(` ${clean}`));
91
+ } else if (!result.success) {
92
+ console.log(chalk.red(` Error: ${result.result}`));
93
+ }
94
+ } catch (err) {
95
+ console.log(chalk.red(` Error: ${err.message}`));
96
+ }
97
+ } else {
98
+ mqtt.sendText(text);
99
+ }
76
100
  rl.prompt();
77
101
  });
78
102