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
@@ -25,35 +25,35 @@ function tryParse(text) {
25
25
  }
26
26
 
27
27
  function formatError(text) {
28
- return `${COLORS.red} ${text}${COLORS.reset}`;
28
+ return `${COLORS.red}[ERR] ${text}${COLORS.reset}`;
29
29
  }
30
30
 
31
31
  function formatFileResult(action, data) {
32
32
  if (action === 'read') {
33
33
  const lines = data.content?.split('\n').length || 0;
34
- return `${COLORS.green}✅${COLORS.reset} ${data.path} (${lines} lines, ${data.totalSize} chars)`;
34
+ return `${COLORS.green}[OK]${COLORS.reset} ${data.path} (${lines} lines, ${data.totalSize} chars)`;
35
35
  }
36
- if (action === 'write') return `${COLORS.green}✅${COLORS.reset} Written: ${data.path} (${data.written} chars)`;
37
- if (action === 'edit') return `${COLORS.green}✅${COLORS.reset} Edited: ${data.path} (${data.occurrences} occurrence(s))`;
36
+ if (action === 'write') return `${COLORS.green}[OK]${COLORS.reset} Written: ${data.path} (${data.written} chars)`;
37
+ if (action === 'edit') return `${COLORS.green}[OK]${COLORS.reset} Edited: ${data.path} (${data.occurrences} occurrence(s))`;
38
38
  if (action === 'list') {
39
39
  const dirs = data.items?.filter(i => i.type === 'dir').length || 0;
40
40
  const files = data.items?.filter(i => i.type === 'file').length || 0;
41
- return `${COLORS.green}✅${COLORS.reset} ${data.path}: ${dirs} dirs, ${files} files`;
41
+ return `${COLORS.green}[OK]${COLORS.reset} ${data.path}: ${dirs} dirs, ${files} files`;
42
42
  }
43
- if (action === 'exists') return data.exists ? `✅ ${data.type}: ${data.path}` : `❌ Not found: ${data.path}`;
44
- if (action === 'delete') return `${COLORS.yellow}🗑️${COLORS.reset} Deleted: ${data.path}`;
45
- return `${COLORS.green}✅${COLORS.reset} ${action}: ${JSON.stringify(data)}`;
43
+ if (action === 'exists') return data.exists ? `[OK] ${data.type}: ${data.path}` : `[ERR] Not found: ${data.path}`;
44
+ if (action === 'delete') return `${COLORS.yellow}[DEL]${COLORS.reset} Deleted: ${data.path}`;
45
+ return `${COLORS.green}[OK]${COLORS.reset} ${action}: ${JSON.stringify(data)}`;
46
46
  }
47
47
 
48
48
  function formatGitResult(action, data) {
49
49
  if (action === 'status') {
50
50
  const dirty = data.dirty ? `${COLORS.yellow}dirty${COLORS.reset}` : `${COLORS.green}clean${COLORS.reset}`;
51
- return `🌿 ${data.branch} (${dirty}) ${data.changes?.length || 0} changes`;
51
+ return `${data.branch} (${dirty}) ${data.changes?.length || 0} changes`;
52
52
  }
53
- if (action === 'log') return `📜 ${data.commits?.length || 0} commits:\n ${data.commits?.join('\n ')}`;
54
- if (action === 'commit') return data.committed ? `${COLORS.green}✅${COLORS.reset} Committed: ${data.message}` : `⚠️ ${data.reason}`;
55
- if (action === 'branch') return data.branches ? `🌿 Branches:\n ${data.branches.join('\n ')}` : `✅ ${data.created || data.deleted}`;
56
- return `${COLORS.green}✅${COLORS.reset} git ${action}: ${JSON.stringify(data)}`;
53
+ if (action === 'log') return `${data.commits?.length || 0} commits:\n ${data.commits?.join('\n ')}`;
54
+ if (action === 'commit') return data.committed ? `${COLORS.green}[OK]${COLORS.reset} Committed: ${data.message}` : `[WARN] ${data.reason}`;
55
+ if (action === 'branch') return data.branches ? `Branches:\n ${data.branches.join('\n ')}` : `[OK] ${data.created || data.deleted}`;
56
+ return `${COLORS.green}[OK]${COLORS.reset} git ${action}: ${JSON.stringify(data)}`;
57
57
  }
58
58
 
59
59
  function formatShellResult(data) {
@@ -65,16 +65,16 @@ function formatShellResult(data) {
65
65
  }
66
66
 
67
67
  function formatSearchResult(action, data) {
68
- if (action === 'grep') return `🔍 ${data.matches} matches for "${data.pattern}":\n ${(data.results || []).join('\n ')}`;
69
- if (action === 'find') return `🔍 ${data.matches} files matching "${data.pattern}":\n ${(data.files || []).join('\n ')}`;
70
- if (action === 'web') return `🌐 ${data.url} (${data.status}):\n${data.body?.slice(0, 500)}`;
68
+ if (action === 'grep') return `${data.matches} matches for "${data.pattern}":\n ${(data.results || []).join('\n ')}`;
69
+ if (action === 'find') return `${data.matches} files matching "${data.pattern}":\n ${(data.files || []).join('\n ')}`;
70
+ if (action === 'web') return `${data.url} (${data.status}):\n${data.body?.slice(0, 500)}`;
71
71
  return JSON.stringify(data, null, 2);
72
72
  }
73
73
 
74
74
  function formatProjectResult(action, data) {
75
- if (action === 'build') return data.success ? `${COLORS.green} Build OK${COLORS.reset} (${data.type})` : `${COLORS.red} Build failed${COLORS.reset} (exit ${data.exitCode})`;
76
- if (action === 'test') return data.passed ? `${COLORS.green} Tests passed${COLORS.reset}` : `${COLORS.red} Tests failed${COLORS.reset} (exit ${data.exitCode})`;
77
- if (action === 'info') return `📁 ${data.name || data.type}: ${data.scripts?.join(', ') || 'no scripts'}`;
75
+ if (action === 'build') return data.success ? `${COLORS.green}[OK] Build OK${COLORS.reset} (${data.type})` : `${COLORS.red}[ERR] Build failed${COLORS.reset} (exit ${data.exitCode})`;
76
+ if (action === 'test') return data.passed ? `${COLORS.green}[OK] Tests passed${COLORS.reset}` : `${COLORS.red}[ERR] Tests failed${COLORS.reset} (exit ${data.exitCode})`;
77
+ if (action === 'info') return `${data.name || data.type}: ${data.scripts?.join(', ') || 'no scripts'}`;
78
78
  return JSON.stringify(data, null, 2);
79
79
  }
80
80
 
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Logger — colored terminal output with levels
3
+ * Supports Ink mode: when active, logs go through bus instead of console
3
4
  */
4
5
  let _debug = false;
6
+ let _inkMode = false;
7
+ let _bus = null;
5
8
 
6
9
  export const COLORS = {
7
10
  reset: '\x1b[0m',
@@ -16,10 +19,16 @@ export const COLORS = {
16
19
  gray: '\x1b[90m',
17
20
  };
18
21
 
22
+ export function setInkMode(enabled, bus) {
23
+ _inkMode = enabled;
24
+ _bus = bus;
25
+ }
26
+ export function isInkMode() { return _inkMode; }
27
+
19
28
  const ICONS = {
20
- ok: '', err: '', warn: '⚠️', info: 'ℹ️',
21
- tool: '🔧', mcp: '🔗', plugin: '🧩', system: '⚙️',
22
- user: '👤', ai: '🤖', debug: '🔍', file: '📁',
29
+ ok: '[OK]', err: '[ERR]', warn: '[WARN]', info: '[i]',
30
+ tool: '[TOOL]', mcp: '[MCP]', plugin: '[PLUG]', system: '[SYS]',
31
+ user: '[USER]', ai: '[AI]', debug: '[DBG]', file: '[FILE]',
23
32
  };
24
33
 
25
34
  export function setDebug(enabled) { _debug = enabled; }
@@ -28,6 +37,13 @@ export function debug(msg, data) { log('debug', msg, data); }
28
37
 
29
38
  export function log(level, msg, data) {
30
39
  if (level === 'debug' && !_debug) return;
40
+
41
+ // In Ink mode, route logs through bus so they render inside the TUI
42
+ if (_inkMode && _bus) {
43
+ _bus.emit('log:message', { level, msg, data });
44
+ return;
45
+ }
46
+
31
47
  const icon = ICONS[level] || '';
32
48
  const color = {
33
49
  ok: COLORS.green, err: COLORS.red, warn: COLORS.yellow,
@@ -39,11 +39,11 @@ export class Spinner {
39
39
  }
40
40
 
41
41
  success(text) {
42
- this.stop(`${COLORS.green} ${text}${COLORS.reset}`);
42
+ this.stop(`${COLORS.green}[OK] ${text}${COLORS.reset}`);
43
43
  }
44
44
 
45
45
  fail(text) {
46
- this.stop(`${COLORS.red} ${text}${COLORS.reset}`);
46
+ this.stop(`${COLORS.red}[ERR] ${text}${COLORS.reset}`);
47
47
  }
48
48
  }
49
49
 
package/src/core/stats.js CHANGED
@@ -41,9 +41,9 @@ export class Stats {
41
41
  formatReport() {
42
42
  const s = this.getSummary();
43
43
  const lines = [
44
- `📊 Session Stats`,
44
+ `[STATS] Session Stats`,
45
45
  ` Uptime: ${s.uptime}s`,
46
- ` Tool calls: ${s.total} (${s.success} / ${s.failed} )`,
46
+ ` Tool calls: ${s.total} (${s.success} [OK] / ${s.failed} [ERR])`,
47
47
  ` Avg duration: ${s.avgDuration}ms`,
48
48
  ];
49
49
  if (s.topTools.length) {
@@ -6,14 +6,14 @@ import { COLORS } from './logger.js';
6
6
  import { bus } from './event-bus.js';
7
7
 
8
8
  const THINK_STATES = {
9
- idle: { icon: '💤', text: 'Idle' },
10
- thinking: { icon: '🤔', text: 'Thinking...' },
11
- tool_call: { icon: '🔧', text: 'Using tool...' },
12
- reading: { icon: '📖', text: 'Reading file...' },
13
- writing: { icon: '✏️', text: 'Writing...' },
14
- searching: { icon: '🔍', text: 'Searching...' },
15
- building: { icon: '🔨', text: 'Building...' },
16
- testing: { icon: '🧪', text: 'Testing...' },
9
+ idle: { icon: '-', text: 'Idle' },
10
+ thinking: { icon: '..', text: 'Thinking...' },
11
+ tool_call: { icon: '>>', text: 'Using tool...' },
12
+ reading: { icon: '..', text: 'Reading file...' },
13
+ writing: { icon: '..', text: 'Writing...' },
14
+ searching: { icon: '..', text: 'Searching...' },
15
+ building: { icon: '..', text: 'Building...' },
16
+ testing: { icon: '..', text: 'Testing...' },
17
17
  };
18
18
 
19
19
  export class Thinking {
@@ -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,35 @@ 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
+ startupLogs.push('');
105
+ startupLogs.push(` Kode Aktivasi: ${code}`);
106
+ startupLogs.push(` https://xiaozhi.me`);
107
+ startupLogs.push('');
82
108
  });
83
109
 
84
110
  try {
85
- log('info', 'Connecting to XiaoZhi...');
86
- await mqtt.connect();
111
+ await withRetry(() => mqtt.connect(), { maxRetries: 2, delay: 2000 });
87
112
  } catch (e) {
88
- log('err', `Connection failed: ${e.message}`);
113
+ console.log = origLog;
114
+ console.error(chalk.red(`\n Connection failed: ${e.message}\n`));
89
115
  process.exit(1);
90
116
  }
91
117
 
92
- // 8. Register MCP tool handler
118
+ // 8. Message sender
119
+ const sender = new MessageSender(mqtt);
120
+
121
+ // 9. Register MCP tool handler
93
122
  mqtt.registerToolHandler(
94
123
  async (name, args) => {
95
124
  const destructive = isDestructive(name, args);
@@ -98,24 +127,31 @@ async function main() {
98
127
  if (!ok) return { isError: true, content: [{ type: 'text', text: 'Cancelled by user' }] };
99
128
  }
100
129
  sandbox.validate(name, args);
130
+
131
+ thinking.set('tool_call');
101
132
  const start = Date.now();
102
- log('tool', `🔧 ${name} → ${args.action || '-'}`);
103
133
  try {
104
134
  const result = await registry.call(name, args);
105
135
  stats.record(name, args.action, !result.isError, Date.now() - start);
106
136
  session.addToolCall(name, args, result);
107
- log('tool', result.isError ? `❌ ${result.content?.[0]?.text}` : `✅ OK`);
137
+
138
+ if (name === 'file' && (args.action === 'write' || args.action === 'edit') && args.path) {
139
+ autoFormat(args.path).catch(() => {});
140
+ }
141
+
142
+ thinking.set('idle');
108
143
  bus.emit('tool:called', { name, result, args });
109
144
  return result;
110
145
  } catch (e) {
111
146
  stats.record(name, args.action, false, Date.now() - start, e.message);
112
- throw e;
147
+ thinking.set('idle');
148
+ throw new Error(handleError(e));
113
149
  }
114
150
  },
115
151
  () => registry.toMcpTools()
116
152
  );
117
153
 
118
- // 9. Optional WSS endpoint
154
+ // 10. Optional WSS endpoint
119
155
  let wss = null;
120
156
  if (config.mcp?.endpoint_url) {
121
157
  wss = new WssEndpoint(config.mcp.endpoint_url, () => registry.toMcpTools(), async (name, args) => {
@@ -125,19 +161,22 @@ async function main() {
125
161
  wss.start();
126
162
  }
127
163
 
128
- // 10. Track conversation
164
+ // 11. Track conversation
129
165
  bus.on('user:text', (text) => session.addUserMessage(text));
130
166
  bus.on('llm:text', (text) => session.addAiMessage(text));
131
167
 
132
- // 11. Save config & start REPL
168
+ // 12. Save config
133
169
  await saveConfig(ROOT, config);
134
- const repl = new REPL(registry, mqtt, stats, session);
135
- repl.start();
170
+
171
+ // 13. Start Ink TUI
172
+ const { unmount, waitUntilExit } = startInkTUI({
173
+ registry, mqtt, stats, session, bus, config,
174
+ extras: { fileTracker, thinking, sender, autoFormat, startupLogs },
175
+ });
136
176
 
137
177
  // Graceful shutdown
138
178
  const shutdown = async () => {
139
- log('info', 'Shutting down...');
140
- console.log(stats.formatReport());
179
+ unmount();
141
180
  if (wss) wss.stop();
142
181
  await registry.shutdown();
143
182
  mqtt.disconnect();
@@ -145,6 +184,12 @@ async function main() {
145
184
  };
146
185
  process.on('SIGINT', shutdown);
147
186
  process.on('SIGTERM', shutdown);
187
+
188
+ await waitUntilExit();
189
+ await shutdown();
148
190
  }
149
191
 
150
- main().catch(e => { log('err', `Fatal: ${e.message}`); process.exit(1); });
192
+ main().catch(e => {
193
+ console.error(chalk.red(`\n Fatal: ${handleError(e)}\n`));
194
+ process.exit(1);
195
+ });
@@ -35,7 +35,7 @@ export async function handleActivation(data, otaUrl, deviceId, clientId) {
35
35
 
36
36
  log('warn', `🔐 Activation required`);
37
37
  log('info', `📋 Code: ${a.code}`);
38
- log('info', `🌐 https://xiaozhi.me`);
38
+ log('info', `[WEB] https://xiaozhi.me`);
39
39
  bus.emit('activation:required', { code: a.code });
40
40
 
41
41
  const timeout = a.timeout_ms || 120000;
@@ -43,10 +43,10 @@ export async function handleActivation(data, otaUrl, deviceId, clientId) {
43
43
 
44
44
  while (Date.now() - start < timeout) {
45
45
  await new Promise(r => setTimeout(r, 5000));
46
- log('info', ' Checking activation...');
46
+ log('info', '[...] Checking activation...');
47
47
  const poll = await fetchOtaConfig(otaUrl, deviceId, clientId);
48
48
  if (!poll.activation?.code && poll.mqtt) {
49
- log('ok', ' Activated!');
49
+ log('ok', '[OK] Activated!');
50
50
  return poll.mqtt;
51
51
  }
52
52
  }
@@ -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(() => {