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
@@ -99,7 +99,7 @@ export function showToolHelp(toolName) {
99
99
  return;
100
100
  }
101
101
 
102
- console.log(`\n${COLORS.bold}📖 ${toolName} — Examples:${COLORS.reset}\n`);
102
+ console.log(`\n${COLORS.bold}[DOC] ${toolName} — Examples:${COLORS.reset}\n`);
103
103
  for (const ex of examples) {
104
104
  console.log(` ${COLORS.cyan}${ex}${COLORS.reset}`);
105
105
  }
@@ -108,7 +108,7 @@ export function showToolHelp(toolName) {
108
108
 
109
109
  export function showAllToolHelp() {
110
110
  for (const [tool, examples] of Object.entries(TOOL_EXAMPLES)) {
111
- console.log(`\n${COLORS.bold}📖 ${tool}:${COLORS.reset}`);
111
+ console.log(`\n${COLORS.bold}[DOC] ${tool}:${COLORS.reset}`);
112
112
  for (const ex of examples.slice(0, 2)) {
113
113
  console.log(` ${COLORS.cyan}${ex}${COLORS.reset}`);
114
114
  }
@@ -18,23 +18,19 @@ export class ToolRegistry {
18
18
  #tools = new Map();
19
19
 
20
20
  async loadBuiltins() {
21
+ const failed = [];
21
22
  try {
22
23
  const files = await readdir(TOOLS_DIR);
23
24
  for (const file of files.filter(f => f.endsWith('.js'))) {
24
25
  try {
25
26
  const mod = await import(join(TOOLS_DIR, file));
26
27
  const tool = mod.default || mod;
27
- if (tool?.name && tool?.handler) {
28
- this.register(tool);
29
- log('tool', ` ${tool.name} — ${tool.description || ''} (${tool.actions?.length || 0} actions)`);
30
- }
31
- } catch (e) {
32
- log('warn', `Tool "${file}" failed: ${e.message}`);
33
- }
28
+ if (tool?.name && tool?.handler) this.register(tool);
29
+ } catch (e) { failed.push(file); }
34
30
  }
35
- } catch (e) {
36
- log('warn', `Tools dir not found: ${e.message}`);
37
- }
31
+ } catch (e) { /* silent */ }
32
+ if (failed.length) log('warn', `Failed tools: ${failed.join(', ')}`);
33
+ return { loaded: this.#tools.size, failed: failed.length };
38
34
  }
39
35
 
40
36
  register(tool) {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Update Checker — compare local version with GitHub releases
3
+ * Uses: https://api.github.com/repos/venenapro/Pelulu-CLI/releases
4
+ */
5
+ import { readFile } from 'fs/promises';
6
+ import { join } from 'path';
7
+
8
+ const GITHUB_RELEASES_URL = 'https://api.github.com/repos/venenapro/Pelulu-CLI/releases';
9
+ const TIMEOUT_MS = 8000;
10
+
11
+ /**
12
+ * Parse semver string "1.2.3" → { major, minor, patch }
13
+ */
14
+ function parseSemver(version) {
15
+ const clean = version.replace(/^v/i, '');
16
+ const match = clean.match(/^(\d+)\.(\d+)\.(\d+)/);
17
+ if (!match) return null;
18
+ return { major: +match[1], minor: +match[2], patch: +match[3] };
19
+ }
20
+
21
+ /**
22
+ * Compare two semver objects.
23
+ * Returns: 1 if a > b, -1 if a < b, 0 if equal
24
+ */
25
+ function compareSemver(a, b) {
26
+ if (a.major !== b.major) return a.major > b.major ? 1 : -1;
27
+ if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
28
+ if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
29
+ return 0;
30
+ }
31
+
32
+ /**
33
+ * Read local version from package.json
34
+ */
35
+ async function getLocalVersion(root) {
36
+ try {
37
+ const raw = await readFile(join(root, 'package.json'), 'utf-8');
38
+ const pkg = JSON.parse(raw);
39
+ return pkg.version || null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Fetch latest release from GitHub API.
47
+ * Returns { tag, name, body, url, publishedAt } or null on failure.
48
+ */
49
+ async function fetchLatestRelease() {
50
+ const controller = new AbortController();
51
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
52
+
53
+ try {
54
+ const res = await fetch(GITHUB_RELEASES_URL, {
55
+ signal: controller.signal,
56
+ headers: {
57
+ 'Accept': 'application/vnd.github+json',
58
+ 'User-Agent': 'Pelulu-CLI',
59
+ },
60
+ });
61
+
62
+ if (!res.ok) return null;
63
+
64
+ const releases = await res.json();
65
+ if (!Array.isArray(releases) || releases.length === 0) return null;
66
+
67
+ // Filter out prerelease/draft, pick latest stable
68
+ const stable = releases.find(r => !r.prerelease && !r.draft) || releases[0];
69
+
70
+ return {
71
+ tag: stable.tag_name || '',
72
+ name: stable.name || stable.tag_name || '',
73
+ body: stable.body || '',
74
+ url: stable.html_url || '',
75
+ publishedAt: stable.published_at || '',
76
+ };
77
+ } catch {
78
+ return null;
79
+ } finally {
80
+ clearTimeout(timer);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Check for updates.
86
+ * Returns:
87
+ * { available: true, local, remote, release } — update available
88
+ * { available: false, local, remote } — up to date
89
+ * { available: false, error: true, message } — check failed
90
+ */
91
+ export async function checkForUpdates(root) {
92
+ const localVersion = await getLocalVersion(root);
93
+ if (!localVersion) {
94
+ return { available: false, error: true, message: 'Could not read local version from package.json' };
95
+ }
96
+
97
+ const release = await fetchLatestRelease();
98
+ if (!release) {
99
+ return { available: false, error: true, local: localVersion, message: 'Could not fetch GitHub releases' };
100
+ }
101
+
102
+ const localParsed = parseSemver(localVersion);
103
+ const remoteParsed = parseSemver(release.tag);
104
+
105
+ if (!localParsed || !remoteParsed) {
106
+ return { available: false, error: true, local: localVersion, remote: release.tag, message: 'Invalid version format' };
107
+ }
108
+
109
+ const cmp = compareSemver(remoteParsed, localParsed);
110
+
111
+ if (cmp > 0) {
112
+ return {
113
+ available: true,
114
+ local: localVersion,
115
+ remote: release.tag.replace(/^v/i, ''),
116
+ release,
117
+ };
118
+ }
119
+
120
+ return {
121
+ available: false,
122
+ local: localVersion,
123
+ remote: release.tag.replace(/^v/i, ''),
124
+ };
125
+ }
@@ -12,7 +12,7 @@ export async function runWizard(root) {
12
12
  if (config._wizard_done) return config;
13
13
 
14
14
  console.log(`\n${COLORS.cyan}╔══════════════════════════════════════╗`);
15
- console.log(`║ 🐾 Pelulu CLI Setup ║`);
15
+ console.log(`║ Pelulu CLI Setup ║`);
16
16
  console.log(`╚══════════════════════════════════════╝${COLORS.reset}\n`);
17
17
 
18
18
  if (!process.stdin.isTTY) {
@@ -31,23 +31,23 @@ export async function runWizard(root) {
31
31
 
32
32
  try {
33
33
  // Workspace
34
- const workspace = await ask('📁 Workspace path?', config.agent?.workspace || '~/coding-agent');
34
+ const workspace = await ask('[DIR] Workspace path?', config.agent?.workspace || '~/coding-agent');
35
35
  config.agent = { ...config.agent, workspace };
36
36
 
37
37
  // MCP endpoint (optional)
38
38
  console.log(`\n${COLORS.dim}MCP Endpoint (optional, for official XiaoZhi MCP):${COLORS.reset}`);
39
39
  console.log(`${COLORS.dim}Get from: xiaozhi.me → Configure → Extensions → MCP Endpoint${COLORS.reset}`);
40
- const mcpUrl = await ask('🔗 MCP Endpoint URL?', '');
40
+ const mcpUrl = await ask('[MCP] MCP Endpoint URL?', '');
41
41
  if (mcpUrl) config.mcp = { ...config.mcp, endpoint_url: mcpUrl };
42
42
 
43
43
  // Shell timeout
44
- const timeout = await ask('⏱️ Shell timeout (ms)?', String(config.tools?.shell_timeout || 30000));
44
+ const timeout = await ask('Shell timeout (ms)?', String(config.tools?.shell_timeout || 30000));
45
45
  config.tools = { ...config.tools, shell_timeout: parseInt(timeout) || 30000 };
46
46
 
47
47
  config._wizard_done = true;
48
48
  await saveConfig(root, config);
49
49
 
50
- console.log(`\n${COLORS.green} Configuration saved!${COLORS.reset}\n`);
50
+ console.log(`\n${COLORS.green}[OK] Configuration saved!${COLORS.reset}\n`);
51
51
  return config;
52
52
  } finally {
53
53
  rl.close();
@@ -82,15 +82,15 @@ export async function detectWorkspace(dir) {
82
82
 
83
83
  export function formatWorkspace(info) {
84
84
  const lines = [
85
- `📁 ${info.name} (${info.type})`,
85
+ `[DIR] ${info.name} (${info.type})`,
86
86
  ` Language: ${info.language}`,
87
87
  ` Path: ${info.path}`,
88
88
  ];
89
89
  if (info.version) lines.push(` Version: ${info.version}`);
90
90
  if (info.scripts?.length) lines.push(` Scripts: ${info.scripts.join(', ')}`);
91
91
  if (info.dirs?.length) lines.push(` Dirs: ${info.dirs.join(', ')}`);
92
- if (info.hasGit) lines.push(' Git: ');
93
- if (info.hasDocker) lines.push(' Docker: ');
94
- if (info.hasCI) lines.push(' CI: ');
92
+ if (info.hasGit) lines.push(' Git: [OK]');
93
+ if (info.hasDocker) lines.push(' Docker: [OK]');
94
+ if (info.hasCI) lines.push(' CI: [OK]');
95
95
  return lines.join('\n');
96
96
  }
package/src/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * XiaoZhi Coding Agent CLI Entry Point
4
- * Usage: cd my-project && xcode
3
+ * Pelulu CLI — Entry Point
4
+ * Ink-based TUI with React components
5
5
  */
6
6
  import { dirname, join } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
+ import chalk from 'chalk';
8
9
  import { loadConfig, saveConfig } from './core/config.js';
9
10
  import { log, setDebug } from './core/logger.js';
10
11
  import { bus } from './core/event-bus.js';
@@ -16,44 +17,70 @@ import { buildSystemPrompt } from './core/system-prompt.js';
16
17
  import { buildContext } from './core/context.js';
17
18
  import { isDestructive, askConfirmation } from './core/confirm.js';
18
19
  import { runWizard } from './core/wizard.js';
20
+ import { handleError } from './core/error-handler.js';
21
+ import { withRetry } from './core/retry.js';
22
+ import { withSpinner } from './core/spinner.js';
23
+ import { Thinking } from './core/thinking.js';
24
+ import { autoFormat } from './core/auto-format.js';
25
+ import { FileTracker } from './core/file-tracker.js';
19
26
  import { MqttClient } from './mcp/mqtt-client.js';
27
+ import { MessageSender } from './mcp/message-sender.js';
20
28
  import { WssEndpoint } from './mcp/wss-endpoint.js';
21
29
  import { PluginManager } from './plugins/manager.js';
22
- import { REPL } from './repl.js';
30
+ import { checkForUpdates } from './core/update-checker.js';
31
+ import { renderUpdateNotification, renderAsciiBanner } from './tui/renderer.js';
32
+ import { startInkTUI } from './tui/ink-entry.js';
23
33
 
24
34
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
35
  const ROOT = join(__dirname, '..');
26
36
 
27
37
  const args = process.argv.slice(2);
28
38
  if (args.includes('--debug')) setDebug(true);
29
- if (args.includes('--wizard')) { /* force wizard */ }
30
39
  const LIST_TOOLS = args.includes('--list-tools');
31
40
 
32
41
  async function main() {
33
- // 1. Load config + wizard
42
+ // Buffer ALL startup logs for Ink to display inside TUI
43
+ const startupLogs = [];
44
+ const origLog = console.log;
45
+ const bufferLog = (...args) => startupLogs.push(args.join(' '));
46
+
47
+ // 1. Load config
34
48
  const config = await loadConfig(ROOT);
35
49
 
36
- // 2. Auto-detect CWD as workspace (like Claude Code, Gemini CLI, etc.)
50
+ // 2. Check for updates block if outdated
51
+ const update = await checkForUpdates(ROOT);
52
+ if (update.available) {
53
+ renderUpdateNotification(update);
54
+ process.exit(1);
55
+ }
56
+
57
+ // 3. Workspace & wizard
37
58
  const cwd = process.cwd();
38
59
  config.agent = { ...config.agent, workspace: cwd };
39
- log('info', `Workspace: ${cwd}`);
40
-
41
60
  if (!config._wizard_done || args.includes('--wizard')) {
42
61
  await runWizard(ROOT);
43
62
  }
44
63
 
45
- // 3. Load tools
64
+ // Show ASCII banner before Ink takes over
65
+ renderAsciiBanner(config.agent?.version);
66
+
67
+ // Redirect console.log to buffer — everything after this goes into Ink
68
+ console.log = bufferLog;
69
+
70
+ // 4. Load tools
46
71
  const registry = new ToolRegistry();
47
- log('info', 'Loading tools...');
48
- await registry.loadBuiltins();
72
+ const pluginMgr = new PluginManager(registry);
49
73
 
50
- // 4. Load plugins
51
- const plugins = new PluginManager(registry);
52
- await plugins.load();
74
+ const { loaded } = await withSpinner('Loading tools...', async () => {
75
+ return await registry.loadBuiltins();
76
+ });
77
+ await pluginMgr.load();
78
+ const actions = registry.all().reduce((s, t) => s + (t.actions?.length || 0), 0);
53
79
 
54
80
  if (LIST_TOOLS) {
81
+ console.log = origLog;
55
82
  const tools = registry.list();
56
- console.log('\n🔧 Tools:\n');
83
+ console.log('\nTools:\n');
57
84
  for (const t of tools) console.log(` ${t.name} — ${t.description} (${t.actions.join(', ')})`);
58
85
  console.log(`\n ${tools.length} tools, ${tools.reduce((s, t) => s + t.actions.length, 0)} actions\n`);
59
86
  process.exit(0);
@@ -63,33 +90,42 @@ async function main() {
63
90
  const sandbox = new Sandbox();
64
91
  const session = new SessionState();
65
92
  const stats = new Stats();
93
+ const thinking = new Thinking();
94
+ const fileTracker = new FileTracker();
66
95
 
67
96
  // 6. Build context & system prompt
68
97
  const context = await buildContext();
69
98
  const systemPrompt = buildSystemPrompt(registry, config);
70
- log('info', `Context: ${context.split('\n').length} lines`);
71
99
 
72
100
  // 7. Connect to XiaoZhi
73
101
  const mqtt = new MqttClient(config);
74
102
 
75
103
  bus.on('activation:required', ({ code }) => {
76
- console.log('');
77
- console.log(` ╔═════════════════════════════════════════════╗`);
78
- console.log(` ║ 🔐 Kode Aktivasi: ${String(code).padEnd(25)}║`);
79
- console.log(` 🌐 https://xiaozhi.me ║`);
80
- console.log(` ╚═════════════════════════════════════════════╝`);
81
- console.log('');
104
+ // Display DIRECTLY to console (not buffered) — user must see this!
105
+ origLog('');
106
+ origLog(chalk.red(' ╔══════════════════════════════════════════╗'));
107
+ origLog(chalk.red('') + chalk.bold.white(' ACTIVATION REQUIRED ') + chalk.red('║'));
108
+ origLog(chalk.red(' ╠══════════════════════════════════════════╣'));
109
+ origLog(chalk.red('') + chalk.yellow(` Code: ${code}`) + ' '.repeat(33 - code.length) + chalk.red('║'));
110
+ origLog(chalk.red(' ║') + chalk.cyan(' https://xiaozhi.me') + ' '.repeat(23) + chalk.red('║'));
111
+ origLog(chalk.red(' ╚══════════════════════════════════════════╝'));
112
+ origLog('');
113
+ origLog(chalk.gray(' Waiting for activation... (checking every 5s)'));
114
+ origLog('');
82
115
  });
83
116
 
84
117
  try {
85
- log('info', 'Connecting to XiaoZhi...');
86
- await mqtt.connect();
118
+ await withRetry(() => mqtt.connect(), { maxRetries: 2, delay: 2000 });
87
119
  } catch (e) {
88
- log('err', `Connection failed: ${e.message}`);
120
+ console.log = origLog;
121
+ console.error(chalk.red(`\n Connection failed: ${e.message}\n`));
89
122
  process.exit(1);
90
123
  }
91
124
 
92
- // 8. Register MCP tool handler
125
+ // 8. Message sender
126
+ const sender = new MessageSender(mqtt);
127
+
128
+ // 9. Register MCP tool handler
93
129
  mqtt.registerToolHandler(
94
130
  async (name, args) => {
95
131
  const destructive = isDestructive(name, args);
@@ -98,24 +134,31 @@ async function main() {
98
134
  if (!ok) return { isError: true, content: [{ type: 'text', text: 'Cancelled by user' }] };
99
135
  }
100
136
  sandbox.validate(name, args);
137
+
138
+ thinking.set('tool_call');
101
139
  const start = Date.now();
102
- log('tool', `🔧 ${name} → ${args.action || '-'}`);
103
140
  try {
104
141
  const result = await registry.call(name, args);
105
142
  stats.record(name, args.action, !result.isError, Date.now() - start);
106
143
  session.addToolCall(name, args, result);
107
- log('tool', result.isError ? `❌ ${result.content?.[0]?.text}` : `✅ OK`);
144
+
145
+ if (name === 'file' && (args.action === 'write' || args.action === 'edit') && args.path) {
146
+ autoFormat(args.path).catch(() => {});
147
+ }
148
+
149
+ thinking.set('idle');
108
150
  bus.emit('tool:called', { name, result, args });
109
151
  return result;
110
152
  } catch (e) {
111
153
  stats.record(name, args.action, false, Date.now() - start, e.message);
112
- throw e;
154
+ thinking.set('idle');
155
+ throw new Error(handleError(e));
113
156
  }
114
157
  },
115
158
  () => registry.toMcpTools()
116
159
  );
117
160
 
118
- // 9. Optional WSS endpoint
161
+ // 10. Optional WSS endpoint
119
162
  let wss = null;
120
163
  if (config.mcp?.endpoint_url) {
121
164
  wss = new WssEndpoint(config.mcp.endpoint_url, () => registry.toMcpTools(), async (name, args) => {
@@ -125,19 +168,22 @@ async function main() {
125
168
  wss.start();
126
169
  }
127
170
 
128
- // 10. Track conversation
171
+ // 11. Track conversation
129
172
  bus.on('user:text', (text) => session.addUserMessage(text));
130
173
  bus.on('llm:text', (text) => session.addAiMessage(text));
131
174
 
132
- // 11. Save config & start REPL
175
+ // 12. Save config
133
176
  await saveConfig(ROOT, config);
134
- const repl = new REPL(registry, mqtt, stats, session);
135
- repl.start();
177
+
178
+ // 13. Start Ink TUI
179
+ const { unmount, waitUntilExit } = startInkTUI({
180
+ registry, mqtt, stats, session, bus, config,
181
+ extras: { fileTracker, thinking, sender, autoFormat, startupLogs },
182
+ });
136
183
 
137
184
  // Graceful shutdown
138
185
  const shutdown = async () => {
139
- log('info', 'Shutting down...');
140
- console.log(stats.formatReport());
186
+ unmount();
141
187
  if (wss) wss.stop();
142
188
  await registry.shutdown();
143
189
  mqtt.disconnect();
@@ -145,6 +191,12 @@ async function main() {
145
191
  };
146
192
  process.on('SIGINT', shutdown);
147
193
  process.on('SIGTERM', shutdown);
194
+
195
+ await waitUntilExit();
196
+ await shutdown();
148
197
  }
149
198
 
150
- main().catch(e => { log('err', `Fatal: ${e.message}`); process.exit(1); });
199
+ main().catch(e => {
200
+ console.error(chalk.red(`\n Fatal: ${handleError(e)}\n`));
201
+ process.exit(1);
202
+ });
@@ -33,9 +33,6 @@ export async function handleActivation(data, otaUrl, deviceId, clientId) {
33
33
  const a = data.activation;
34
34
  if (!a?.code) return data.mqtt;
35
35
 
36
- log('warn', `🔐 Activation required`);
37
- log('info', `📋 Code: ${a.code}`);
38
- log('info', `🌐 https://xiaozhi.me`);
39
36
  bus.emit('activation:required', { code: a.code });
40
37
 
41
38
  const timeout = a.timeout_ms || 120000;
@@ -43,10 +40,10 @@ export async function handleActivation(data, otaUrl, deviceId, clientId) {
43
40
 
44
41
  while (Date.now() - start < timeout) {
45
42
  await new Promise(r => setTimeout(r, 5000));
46
- log('info', 'Checking activation...');
43
+ log('info', 'Checking activation...');
47
44
  const poll = await fetchOtaConfig(otaUrl, deviceId, clientId);
48
45
  if (!poll.activation?.code && poll.mqtt) {
49
- log('ok', 'Activated!');
46
+ log('ok', 'Activated!');
50
47
  return poll.mqtt;
51
48
  }
52
49
  }
@@ -70,7 +70,7 @@ export class MessageSender {
70
70
  async _waitForReady() {
71
71
  if (this.mqtt.mcp.toolsReceived) return;
72
72
 
73
- log('info', ' Waiting for MCP handshake...');
73
+ log('info', '[...] Waiting for MCP handshake...');
74
74
  return new Promise((resolve, reject) => {
75
75
  const timeout = setTimeout(() => reject(new Error('MCP handshake timeout')), 30000);
76
76
  const check = setInterval(() => {
@@ -111,9 +111,9 @@ export class MqttClient {
111
111
  for (const r of responses) {
112
112
  if (r.type === 'mcp') { this._send(r); }
113
113
  else if (r.type === 'tool_call') {
114
- log('tool', `🔧 ${r.name}`);
114
+ log('tool', r.name);
115
115
  this.mcp.executeTool(r.name, r.args)
116
- .then(result => { log('tool', result.isError ? '' : ''); this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result } }); })
116
+ .then(result => { log('tool', result.isError ? 'failed' : 'done'); this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result } }); })
117
117
  .catch(err => { this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result: { content: [{ type: 'text', text: err.message }], isError: true } } }); });
118
118
  }
119
119
  }
@@ -124,7 +124,7 @@ export class MqttClient {
124
124
  }
125
125
 
126
126
  _onOther(msg) {
127
- if (msg.type === 'stt') { log('user', `🎤 "${msg.text}"`); bus.emit('stt', msg.text); }
127
+ if (msg.type === 'stt') { log('user', `"${msg.text}"`); bus.emit('stt', msg.text); }
128
128
  if (msg.type === 'llm' && msg.text) { bus.emit('llm:text', msg.text); }
129
129
  if (msg.type === 'tts' && msg.state === 'sentence_start' && msg.text) { bus.emit('tts:sentence', msg.text); }
130
130
  if (msg.type === 'goodbye') { this.sessionId = null; this.mcp.reset(); this._helloQueue = []; }
@@ -138,7 +138,7 @@ export class MqttClient {
138
138
 
139
139
  sendText(text) {
140
140
  this._send({ type: 'listen', state: 'detect', text });
141
- log('user', `💬 "${text}"`);
141
+ log('user', `"${text}"`);
142
142
  bus.emit('user:text', text);
143
143
  }
144
144
 
@@ -76,7 +76,7 @@ export async function handleCommand(cmd, arg, ctx) {
76
76
  case '/history': {
77
77
  const recent = history.slice(-10);
78
78
  if (!recent.length) { log('info', 'No history'); break; }
79
- console.log(`\n${COLORS.bold}📜 Recent:${COLORS.reset}\n`);
79
+ console.log(`\n${COLORS.bold}[LOG] Recent:${COLORS.reset}\n`);
80
80
  for (const h of recent) console.log(` ${COLORS.dim}${new Date(h.ts).toLocaleTimeString()}${COLORS.reset} ${h.text.slice(0, 80)}`);
81
81
  console.log();
82
82
  break;
@@ -84,6 +84,7 @@ export async function handleCommand(cmd, arg, ctx) {
84
84
 
85
85
  case '/doctor': await runDoctor(); break;
86
86
  case '/model': console.log(`\n${formatModelInfo()}\n`); break;
87
+ case '/keys': return 'keys';
87
88
  case '/clear': console.clear(); break;
88
89
  default: log('info', `Unknown: ${cmd}. /help`);
89
90
  }
package/src/repl.js CHANGED
@@ -9,6 +9,8 @@ import { formatToolResult } from './core/formatter.js';
9
9
  import { parseIntent } from './core/intent.js';
10
10
  import { FileTracker } from './core/file-tracker.js';
11
11
  import { handleCommand } from './repl-commands.js';
12
+ import { getCompletions } from './core/completion.js';
13
+ import { formatKeybindings } from './core/keybindings.js';
12
14
  import {
13
15
  renderBanner, renderStatus, renderTools, renderHelp,
14
16
  renderToolCall, renderToolResult, renderAiResponse,
@@ -16,19 +18,21 @@ import {
16
18
  } from './tui/renderer.js';
17
19
 
18
20
  export class REPL {
19
- constructor(registry, mqtt, stats, session) {
21
+ constructor(registry, mqtt, stats, session, extras = {}) {
20
22
  this.registry = registry;
21
23
  this.mqtt = mqtt;
22
24
  this.stats = stats;
23
25
  this.session = session;
24
26
  this.fileTracker = new FileTracker();
27
+ this.thinking = extras.thinking || null;
28
+ this.sender = extras.sender || null;
29
+ this.autoFormat = extras.autoFormat || null;
25
30
  this.rl = null;
26
31
  this.history = [];
27
32
  }
28
33
 
29
34
  start() {
30
35
  const config = getConfig();
31
- renderBanner(config, this.registry.all(), this.mqtt.connected);
32
36
  this._events();
33
37
 
34
38
  if (!process.stdin.isTTY) {
@@ -41,6 +45,10 @@ export class REPL {
41
45
  this.rl = createInterface({
42
46
  input: process.stdin, output: process.stdout,
43
47
  prompt: createPrompt(dirName), historySize: 200,
48
+ completer: (line) => {
49
+ const hits = getCompletions(line);
50
+ return [hits.length ? hits : [], line];
51
+ },
44
52
  });
45
53
  this.rl.prompt();
46
54
  this.rl.on('line', (input) => this._line(input.trim()));
@@ -49,12 +57,15 @@ export class REPL {
49
57
 
50
58
  _events() {
51
59
  bus.on('llm:text', (text) => {
60
+ if (this.thinking) this.thinking.set('idle');
61
+ // Clear any thinking indicator line
62
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
52
63
  renderAiResponse(text);
53
64
  if (this.rl) this.rl.prompt();
54
65
  });
55
66
 
56
67
  bus.on('tts:sentence', (text) => {
57
- console.log(chalk.dim(` 🔊 ${text}`));
68
+ // TTS is audio output, no need to display text
58
69
  });
59
70
 
60
71
  bus.on('tool:called', ({ name, result, args }) => {
@@ -63,7 +74,7 @@ export class REPL {
63
74
  });
64
75
 
65
76
  bus.on('ready', () => {
66
- console.log(chalk.green(' ✅ XiaoZhi session ready\n'));
77
+ if (this.rl) this.rl.prompt();
67
78
  });
68
79
  }
69
80
 
@@ -75,7 +86,11 @@ export class REPL {
75
86
  const intent = parseIntent(input);
76
87
  if (intent.matched) {
77
88
  renderUserInput(input);
89
+ if (this.thinking) this.thinking.set('tool_call');
78
90
  const result = await this.registry.call(intent.tool, intent.params);
91
+ if (this.thinking) this.thinking.set('idle');
92
+ // Clear thinking indicator
93
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
79
94
  const formatted = formatToolResult(intent.tool, intent.action, result);
80
95
  console.log(`\n${formatted}\n`);
81
96
  this.history.push({ role: 'user', text: input, ts: Date.now() });
@@ -86,6 +101,7 @@ export class REPL {
86
101
  // Send to XiaoZhi
87
102
  renderUserInput(input);
88
103
  this.history.push({ role: 'user', text: input, ts: Date.now() });
104
+ if (this.thinking) this.thinking.set('thinking');
89
105
  this.mqtt.sendText(input);
90
106
  this.rl?.prompt();
91
107
  }
@@ -93,15 +109,19 @@ export class REPL {
93
109
  async _cmd(input) {
94
110
  const [cmd, ...rest] = input.split(' ');
95
111
  const arg = rest.join(' ');
96
- const ctx = { registry: this.registry, mqtt: this.mqtt, stats: this.stats, session: this.session, fileTracker: this.fileTracker, history: this.history };
112
+ const ctx = {
113
+ registry: this.registry, mqtt: this.mqtt, stats: this.stats,
114
+ session: this.session, fileTracker: this.fileTracker, history: this.history,
115
+ };
97
116
  const special = await handleCommand(cmd, arg, ctx);
98
117
 
99
118
  if (special === 'help') renderHelp();
100
119
  if (special === 'tools') renderTools(this.registry.list());
120
+ if (special === 'keys') console.log(formatKeybindings());
101
121
  if (special === 'status') {
102
122
  const s = this.session.getStats();
103
123
  renderStatus({
104
- 'MQTT': this.mqtt.connected ? ' Connected' : ' Disconnected',
124
+ 'MQTT': this.mqtt.connected ? '[OK] Connected' : '[ERR] Disconnected',
105
125
  'Session': this.mqtt.sessionId || 'none',
106
126
  'Tools': `${this.registry.all().length} (${this.registry.all().reduce((s, t) => s + (t.actions?.length || 0), 0)} actions)`,
107
127
  'Turns': s.turns,
@@ -36,7 +36,7 @@ const ACTIONS = {
36
36
  const config = getConfig();
37
37
  setNested(config, key, value);
38
38
  await saveConfig(config._root, config);
39
- log('config', `⚙️ Set ${key} = ${value}`);
39
+ log('config', `[CFG] Set ${key} = ${value}`);
40
40
  return { key, value, saved: true };
41
41
  },
42
42
  },