pelulu-cli 1.0.5 → 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.
@@ -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
 
@@ -3,7 +3,12 @@
3
3
  * Like Claude Code / Gemini CLI / OpenCode
4
4
  */
5
5
  import chalk from 'chalk';
6
- import { createInterface } from 'readline';
6
+ import { readFile } from 'fs/promises';
7
+ import { join, dirname } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const ROOT = join(__dirname, '..', '..');
7
12
 
8
13
  const box = {
9
14
  tl: '╭', tr: '╮', bl: '╰', br: '╯',
@@ -23,20 +28,64 @@ function center(text, width) {
23
28
  return ' '.repeat(left) + text + ' '.repeat(width - left - text.length);
24
29
  }
25
30
 
31
+ /**
32
+ * Read version from package.json (cached)
33
+ */
34
+ let _cachedVersion = null;
35
+ async function getVersion() {
36
+ if (_cachedVersion) return _cachedVersion;
37
+ try {
38
+ const pkg = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf-8'));
39
+ _cachedVersion = pkg.version || '0.0.0';
40
+ } catch {
41
+ _cachedVersion = '0.0.0';
42
+ }
43
+ return _cachedVersion;
44
+ }
45
+
46
+ function stripAnsi(str) {
47
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
48
+ }
49
+
26
50
  /**
27
51
  * Render the Pelulu ASCII art banner (displayed once at startup)
28
52
  */
29
- export function renderAsciiBanner(version) {
30
- const v = version ? `v.${version}` : '';
31
- const lines = [
32
- chalk.cyan(' /\\_/\\ '),
33
- chalk.cyan(' ( o.o ) ') + chalk.bold.cyan(` P E L U L U - C L I`),
34
- chalk.cyan(' > ^ < ') + chalk.gray(` ${v} | powered by XiaoZhi`),
35
- chalk.cyan(' /| |\\ '),
36
- chalk.cyan('(_| |_)') + chalk.dim(' the tiny coding companion'),
53
+ export async function renderAsciiBanner() {
54
+ const version = await getVersion();
55
+ const w = 52;
56
+
57
+ const cat = [
58
+ ' /\\_/\\ ',
59
+ ' ( o.o ) ',
60
+ ' > ^ < ',
61
+ ' /| |\\ ',
62
+ ' (_| |_)',
37
63
  ];
64
+
65
+ const info = [
66
+ chalk.cyan.bold('P E L U L U - C L I'),
67
+ chalk.gray(`v${version}`),
68
+ chalk.cyan('coding companion'),
69
+ chalk.gray('powered by XiaoZhi'),
70
+ '',
71
+ ];
72
+
38
73
  console.log('');
39
- for (const line of lines) console.log(line);
74
+ console.log(chalk.cyan(`${box.tl}${horizontal(w, box.h)}${box.tr}`));
75
+
76
+ for (let i = 0; i < Math.max(cat.length, info.length); i++) {
77
+ const left = cat[i] ? chalk.cyan(cat[i]) : ' '.repeat(11);
78
+ const right = info[i] || '';
79
+ const gap = ' ';
80
+ console.log(chalk.cyan(`${box.v}`) + left + gap + right + ' '.repeat(Math.max(0, w - 11 - gap.length - stripAnsi(right).length)) + chalk.cyan(`${box.v}`));
81
+ }
82
+
83
+ console.log(chalk.cyan(`${box.ml}${horizontal(w, box.h)}${box.mr}`));
84
+
85
+ const features = chalk.gray(' 18 tools • MCP protocol • agent mode');
86
+ console.log(chalk.cyan(`${box.v}`) + features + ' '.repeat(Math.max(0, w - stripAnsi(features).length)) + chalk.cyan(`${box.v}`));
87
+
88
+ console.log(chalk.cyan(`${box.bl}${horizontal(w, box.h)}${box.br}`));
40
89
  console.log('');
41
90
  }
42
91
 
@@ -100,12 +149,7 @@ export function renderToolResult(success, data) {
100
149
  }
101
150
  }
102
151
 
103
- /**
104
- * Strip emoji and decorative unicode from text.
105
- * Keeps ASCII, CJK, common punctuation.
106
- */
107
152
  function stripEmojis(text) {
108
- // Remove emoji (Unicode ranges), box-drawing decorative symbols, and common decorative chars
109
153
  return text
110
154
  .replace(/\p{Emoji_Presentation}/gu, '')
111
155
  .replace(/\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?/gu, '')
@@ -122,7 +166,6 @@ export function renderAiResponse(text) {
122
166
  const clean = stripEmojis(text);
123
167
  if (!clean) return;
124
168
  console.log('');
125
- // Wrap long lines at 80 chars
126
169
  const lines = wrapText(clean, 80);
127
170
  for (const line of lines) {
128
171
  console.log(chalk.white(` ${line}`));
@@ -183,10 +226,6 @@ export function createPrompt(dirName) {
183
226
  return chalk.cyan(`${dirName} `) + chalk.white('❯ ');
184
227
  }
185
228
 
186
- /**
187
- * Render update notification banner
188
- * @param {object} update - { local, remote, release: { tag, name, url, body } }
189
- */
190
229
  export function renderUpdateNotification(update) {
191
230
  const w = 56;
192
231
  const { local, remote, release } = update;
@@ -197,40 +236,42 @@ export function renderUpdateNotification(update) {
197
236
  console.log(chalk.yellow(`${box.ml}${horizontal(w, box.h)}${box.mr}`));
198
237
  console.log(chalk.yellow(`${box.v}`) + chalk.white(pad(` Versi lokal : ${local}`, w)) + chalk.yellow(`${box.v}`));
199
238
  console.log(chalk.yellow(`${box.v}`) + chalk.green(pad(` Versi terbaru : ${remote}`, w)) + chalk.yellow(`${box.v}`));
200
-
201
- if (release?.name && release.name !== release.tag) {
202
- console.log(chalk.yellow(`${box.v}`) + chalk.gray(pad(` Release : ${release.name}`, w)) + chalk.yellow(`${box.v}`));
203
- }
204
-
205
- if (release?.url) {
206
- console.log(chalk.yellow(`${box.v}`) + chalk.cyan(pad(` ${release.url}`, w)) + chalk.yellow(`${box.v}`));
207
- }
208
-
209
- console.log(chalk.yellow(`${box.ml}${horizontal(w, box.h)}${box.mr}`));
210
- console.log(chalk.yellow(`${box.v}`) + chalk.bold.white(pad(' Jalankan perintah berikut untuk update:', w)) + chalk.yellow(`${box.v}`));
211
- console.log(chalk.yellow(`${box.v}`) + chalk.green(pad(' npm install pelulu-cli@latest', w)) + chalk.yellow(`${box.v}`));
239
+ console.log(chalk.yellow(`${box.v}`) + chalk.gray(pad(' Menginstall update secara otomatis...', w)) + chalk.yellow(`${box.v}`));
212
240
  console.log(chalk.yellow(`${box.bl}${horizontal(w, box.h)}${box.br}`));
213
241
  console.log('');
214
242
  }
215
243
 
216
244
  /**
217
- * Render update check failure (silent, dim)
245
+ * Render usage info after successful update
218
246
  */
247
+ export function renderPostUpdate(packageName, version) {
248
+ const w = 52;
249
+ console.log('');
250
+ console.log(chalk.green(`${box.tl}${horizontal(w, box.h)}${box.tr}`));
251
+ console.log(chalk.green(`${box.v}`) + chalk.bold.green(center(` ✓ ${packageName} v${version} installed!`, w)) + chalk.green(`${box.v}`));
252
+ console.log(chalk.green(`${box.ml}${horizontal(w, box.h)}${box.mr}`));
253
+ console.log(chalk.green(`${box.v}`) + chalk.white(pad(' Run:', w)) + chalk.green(`${box.v}`));
254
+ console.log(chalk.green(`${box.v}`) + chalk.cyan(pad(` $ ${packageName}`, w)) + chalk.green(`${box.v}`));
255
+ console.log(chalk.green(`${box.v}`) + chalk.gray(pad('', w)) + chalk.green(`${box.v}`));
256
+ console.log(chalk.green(`${box.v}`) + chalk.white(pad(' Commands:', w)) + chalk.green(`${box.v}`));
257
+ console.log(chalk.green(`${box.v}`) + chalk.gray(pad(' /help — show commands', w)) + chalk.green(`${box.v}`));
258
+ console.log(chalk.green(`${box.v}`) + chalk.gray(pad(' /tools — list tools', w)) + chalk.green(`${box.v}`));
259
+ console.log(chalk.green(`${box.v}`) + chalk.gray(pad(' /status — connection status', w)) + chalk.green(`${box.v}`));
260
+ console.log(chalk.green(`${box.v}`) + chalk.gray(pad(' /clear — clear screen', w)) + chalk.green(`${box.v}`));
261
+ console.log(chalk.green(`${box.v}`) + chalk.gray(pad(' /quit — exit', w)) + chalk.green(`${box.v}`));
262
+ console.log(chalk.green(`${box.bl}${horizontal(w, box.h)}${box.br}`));
263
+ console.log('');
264
+ }
265
+
219
266
  export function renderUpdateError(message) {
220
267
  console.log(chalk.dim(` [WARN] Update check failed: ${message}`));
221
268
  }
222
269
 
223
- /**
224
- * Render a clean init status line (replaces verbose per-tool logging)
225
- */
226
270
  export function renderInitLine(icon, text, detail = '') {
227
271
  const detailStr = detail ? chalk.dim(` (${detail})`) : '';
228
272
  console.log(chalk.gray(` ${icon} ${text}`) + detailStr);
229
273
  }
230
274
 
231
- /**
232
- * Render ready line with session info
233
- */
234
275
  export function renderReady(sessionId) {
235
276
  console.log(chalk.green(` ✓ Ready`) + chalk.dim(` session: ${sessionId || '-'}`));
236
277
  console.log('');