pelulu-cli 1.1.0 → 1.2.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.
@@ -9,6 +9,7 @@ import {
9
9
  } from './ink-components.js';
10
10
  import { CompletableInput } from './completable-input.js';
11
11
  import { setInkMode } from '../core/logger.js';
12
+ import { jobManager } from '../core/job-manager.js';
12
13
 
13
14
  export function createApp({ registry, mqtt, stats, session, bus, config, extras }) {
14
15
  return function App() {
@@ -28,13 +29,36 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
28
29
  const maxMessages = 200;
29
30
  const [scrollOffset, setScrollOffset] = useState(0); // 0 = bottom, N = scrolled up N messages
30
31
  const scrollOffsetRef = useRef(0);
32
+ // Watchdog + background task phase tracking
33
+ const activityTimer = useRef(null); // LLM/agent "thinking" watchdog
34
+ const taskTimer = useRef(null); // long-running task liveness watchdog
35
+ const lastPhaseRef = useRef(null);
36
+ const taskRunningRef = useRef(false); // a background task is active
37
+ const agentBusyRef = useRef(false); // a XiaoZhi turn is actively in progress
38
+ const wasConnectedRef = useRef(mqtt.connected); // dedupe "connected" spam
39
+ const STUCK_TIMEOUT_MS = config?.agent?.stuck_timeout_ms || 45000;
40
+ // A single step can legitimately run a while; only warn if a task
41
+ // produces NO progress events at all for this long.
42
+ const TASK_STUCK_MS = config?.agent?.task_timeout_ms || 90000;
31
43
 
32
- // Calculate how many rows are available for messages
33
- const MAX_VISIBLE_MESSAGES = 12;
34
- const getAvailableRows = useCallback(() => {
35
- return MAX_VISIBLE_MESSAGES;
44
+ // Track terminal height so the whole UI always fits inside the viewport.
45
+ // If the total output is taller than the terminal, Ink cannot erase the
46
+ // previous frame and re-prints the top border on every render — this is
47
+ // the "duplicate banner" bug. Keeping the tree <= terminal rows avoids it.
48
+ const [rows, setRows] = useState(process.stdout.rows || 24);
49
+ useEffect(() => {
50
+ const onResize = () => setRows(process.stdout.rows || 24);
51
+ process.stdout.on('resize', onResize);
52
+ return () => process.stdout.off('resize', onResize);
36
53
  }, []);
37
54
 
55
+ // Chrome that is ALWAYS present around the message window:
56
+ // banner (7) + status bar (3) + input box (3) + message paddingY (2) = 15
57
+ // Dynamic single-line chrome (status line, thinking, scroll hints) is
58
+ // accounted for at render time so the frame never exceeds the terminal
59
+ // height — overflow is what causes the duplicated banner + vanishing chat.
60
+ const BASE_CHROME = 15;
61
+
38
62
  // ─── Enable Ink mode for logger ──────────────────
39
63
  useEffect(() => {
40
64
  setInkMode(true, bus);
@@ -48,6 +72,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
48
72
  useEffect(() => {
49
73
  const onLlmText = (text) => {
50
74
  setThinking('idle');
75
+ if (activityTimer.current) { clearTimeout(activityTimer.current); activityTimer.current = null; }
51
76
  // Strip emojis to check if there's real content
52
77
  const clean = text.replace(/\p{Emoji_Presentation}/gu, '').replace(/\p{Extended_Pictographic}/gu, '').trim();
53
78
  if (!clean) return; // skip emoji-only LLM responses (TTS will carry the real text)
@@ -57,6 +82,8 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
57
82
  };
58
83
 
59
84
  const onToolCalled = ({ name, result, args }) => {
85
+ // A tool just finished -> refresh the watchdog (agent is alive)
86
+ if (activityTimer.current) { clearTimeout(activityTimer.current); activityTimer.current = null; }
60
87
  setMessages(prev => [...prev.slice(-maxMessages), {
61
88
  id: `tool-${Date.now()}`, role: 'tool',
62
89
  toolName: name, action: args?.action,
@@ -70,14 +97,164 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
70
97
  }]);
71
98
  };
72
99
 
73
- const onThinking = ({ state }) => setThinking(state);
100
+ // File change tracking surface each tracked change as a chat line
101
+ const onFilesChanged = ({ path, change }) => {
102
+ const short = path.replace(process.cwd() + '/', '');
103
+ setMessages(prev => [...prev.slice(-maxMessages), {
104
+ id: `track-${Date.now()}`, role: 'system',
105
+ content: `tracked: ${change} ${short}`,
106
+ }]);
107
+ };
108
+
109
+ const pushSystem = (content) => setMessages(prev => [...prev.slice(-maxMessages), {
110
+ id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, role: 'system', content,
111
+ }]);
112
+
113
+ // Watchdog: if the agent is "busy" but nothing happens for a while,
114
+ // recover the UI instead of hanging forever on ".. using tool...".
115
+ const clearWatchdog = () => {
116
+ if (activityTimer.current) { clearTimeout(activityTimer.current); activityTimer.current = null; }
117
+ };
118
+ const armWatchdog = () => {
119
+ clearWatchdog();
120
+ activityTimer.current = setTimeout(() => {
121
+ setThinking('idle');
122
+ pushSystem('still waiting on XiaoZhi to reply — you can keep typing meanwhile.');
123
+ }, STUCK_TIMEOUT_MS);
124
+ };
125
+
126
+ const onThinking = ({ state }) => {
127
+ // While a background task owns the UI, ignore transient
128
+ // "thinking/tool_call" states so we never get stuck on ".. using tool..".
129
+ if (taskRunningRef.current) return;
130
+ setThinking(state);
131
+ if (state && state !== 'idle') armWatchdog();
132
+ else clearWatchdog();
133
+ };
74
134
  const onReady = () => {
75
135
  setConnected(true);
76
136
  setSessionId(mqtt.sessionId);
137
+ // Only announce a real disconnected -> connected transition, so
138
+ // reconnects / repeated hellos don't spam and look like a reset.
139
+ if (!wasConnectedRef.current) pushSystem('connected to XiaoZhi.');
140
+ wasConnectedRef.current = true;
77
141
  };
78
- const onDisconnect = () => {
142
+ const onDisconnect = (err) => {
79
143
  setConnected(false);
80
144
  setSessionId(null);
145
+ setThinking('idle');
146
+ clearWatchdog();
147
+ lastPhaseRef.current = null;
148
+ if (wasConnectedRef.current) {
149
+ pushSystem(`disconnected${err?.message ? ` (${err.message})` : ''} — reconnecting...`);
150
+ }
151
+ wasConnectedRef.current = false;
152
+ };
153
+ const onSessionEnd = (info) => {
154
+ // XiaoZhi ended the audio session (idle `goodbye`). The next message now
155
+ // auto-reconnects (see mqtt.ensureSession), so this is silent unless the
156
+ // user is waiting.
157
+ setSessionId(null);
158
+ // Don't nag while work is still in progress. A `goodbye` that lands mid
159
+ // turn is handled by mqtt-client (it re-opens the session so the current
160
+ // task keeps talking to XiaoZhi); showing "session paused" here would be
161
+ // wrong and alarming. Background tasks run locally and don't need the
162
+ // session either.
163
+ if (taskRunningRef.current || agentBusyRef.current || info?.turnActive) return;
164
+ setThinking('idle');
165
+ clearWatchdog();
166
+ // Surface a subtle, reassuring notice so a paused session doesn't look
167
+ // like a silent reset — the next message reconnects automatically.
168
+ pushSystem('XiaoZhi session paused — it resumes automatically when you send your next message.');
169
+ };
170
+ const onSessionDead = () => {
171
+ // ensureSession() failed to re-establish — tell the user explicitly
172
+ // instead of silently dropping their message.
173
+ setThinking('idle');
174
+ clearWatchdog();
175
+ pushSystem('could not reach XiaoZhi (session lost) — check connection and try again.');
176
+ };
177
+
178
+ // Reconnection lifecycle — keep the user informed instead of silently
179
+ // dropping the connection (the old "disconnected forever" symptom).
180
+ const onReconnecting = (data) => {
181
+ setConnected(false);
182
+ setSessionId(null);
183
+ pushSystem(`reconnecting to XiaoZhi (attempt ${data?.attempt || 1})...`);
184
+ wasConnectedRef.current = false;
185
+ };
186
+ const onReconnected = () => {
187
+ setConnected(true);
188
+ setSessionId(mqtt.sessionId);
189
+ pushSystem('reconnected to XiaoZhi.');
190
+ wasConnectedRef.current = true;
191
+ };
192
+
193
+ // Long-running task liveness watchdog — reset on EVERY progress event.
194
+ // This replaces the old per-phase timer that false-fired during slow
195
+ // steps (the bogus "no response (timed out)" mid-task).
196
+ const bumpTaskWatchdog = (label) => {
197
+ if (taskTimer.current) clearTimeout(taskTimer.current);
198
+ taskTimer.current = setTimeout(() => {
199
+ taskRunningRef.current = false;
200
+ setThinking('idle');
201
+ pushSystem(`${label} appears stalled (no progress ${Math.round(TASK_STUCK_MS / 1000)}s) — check with "jobs" tool or try again.`);
202
+ }, TASK_STUCK_MS);
203
+ };
204
+ const clearTaskWatchdog = () => {
205
+ if (taskTimer.current) { clearTimeout(taskTimer.current); taskTimer.current = null; }
206
+ };
207
+
208
+ // Background task progress -> surface inline in the chat
209
+ const onTaskProgress = (data) => {
210
+ if (!data) return;
211
+ const label = data.target || data.tool || 'task';
212
+ setLogLine(`[${data.tool || 'task'}] ${data.target || ''} ${data.phase || ''} ${data.elapsed || 0}s`.trim());
213
+
214
+ if (data.running) {
215
+ taskRunningRef.current = true;
216
+ setThinking('idle'); // never show the generic "using tool"
217
+ bumpTaskWatchdog(label); // any event = still alive
218
+ // Announce each new phase inline
219
+ if (data.phase && data.phase !== lastPhaseRef.current) {
220
+ lastPhaseRef.current = data.phase;
221
+ pushSystem(`» ${label}: ${data.phase}${data.log ? ` — ${data.log}` : ''}`);
222
+ }
223
+ }
224
+
225
+ if (!data.running || data.phase === 'done') {
226
+ clearTaskWatchdog();
227
+ taskRunningRef.current = false;
228
+ if (lastPhaseRef.current !== 'done') {
229
+ lastPhaseRef.current = 'done';
230
+ pushSystem(`» ${label}: finished`);
231
+ }
232
+ setThinking('idle');
233
+ }
234
+ };
235
+
236
+ // ─── Universal background-job feedback ───────────
237
+ // Any tool action that runs past the grace window becomes a pollable
238
+ // background job. We announce it, stream its progress on the log line,
239
+ // and confirm completion inline — so EVERY tool gives continuous
240
+ // feedback and never looks "stuck".
241
+ const bgJobs = new Set();
242
+ const onJobBackgrounded = (snap) => {
243
+ if (!snap) return;
244
+ bgJobs.add(snap.id);
245
+ setThinking('idle');
246
+ pushSystem(`~ ${snap.label} is running in the background (${snap.id}); I'll report when it finishes.`);
247
+ };
248
+ const onJobProgress = (p) => {
249
+ if (!p) return;
250
+ setLogLine(`[${p.tool}] ${p.message}`.slice(0, 120));
251
+ };
252
+ const onJobDone = (snap) => {
253
+ if (!snap || !bgJobs.has(snap.id)) return; // only announce jobs we flagged as background
254
+ bgJobs.delete(snap.id);
255
+ if (snap.status === 'error') { pushSystem(`x ${snap.label} failed: ${snap.error}`); return; }
256
+ if (snap.status === 'cancelled') { pushSystem(`- ${snap.label} cancelled.`); return; }
257
+ pushSystem(`+ ${snap.label} finished in ${snap.elapsed_s}s.`);
81
258
  };
82
259
 
83
260
  // Single log line that updates in place
@@ -98,6 +275,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
98
275
  const onTtsSentence = (text) => {
99
276
  ttsBuffer += text;
100
277
  setThinking('idle');
278
+ if (activityTimer.current) { clearTimeout(activityTimer.current); activityTimer.current = null; }
101
279
  // Debounce: wait for all TTS sentences to arrive, then display
102
280
  if (ttsTimer) clearTimeout(ttsTimer);
103
281
  ttsTimer = setTimeout(() => {
@@ -119,39 +297,81 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
119
297
  // Agent progress events
120
298
  const onAgentProgress = ({ state, message, tool, action }) => {
121
299
  if (state === 'tool') {
300
+ agentBusyRef.current = true;
122
301
  setThinking('tool_call');
123
302
  setLogLine(`[TOOL] ${tool}.${action || ''}...`);
303
+ armWatchdog();
124
304
  } else if (state === 'tool_done') {
305
+ agentBusyRef.current = true;
125
306
  setThinking('thinking');
126
307
  setLogLine(`[TOOL] done, waiting...`);
308
+ armWatchdog();
127
309
  } else if (state === 'receiving') {
310
+ agentBusyRef.current = true;
128
311
  setLogLine(`[LLM] ${message}`);
312
+ armWatchdog();
129
313
  } else if (state === 'thinking') {
314
+ agentBusyRef.current = true;
130
315
  setThinking('thinking');
316
+ armWatchdog();
317
+ } else if (state === 'done') {
318
+ // The turn finished. Without this branch the watchdog armed by the
319
+ // last tool_done/receiving event was never cleared, so it fired
320
+ // ~45s later as a bogus "still waiting on XiaoZhi to reply" even
321
+ // though XiaoZhi had already answered. Clear it and go idle.
322
+ agentBusyRef.current = false;
323
+ setThinking('idle');
324
+ clearWatchdog();
131
325
  } else if (state === 'timeout') {
326
+ agentBusyRef.current = false;
327
+ setThinking('idle');
328
+ clearWatchdog();
132
329
  setLogLine(`[WARN] ${message}`);
330
+ pushSystem(`timeout: ${message}`);
133
331
  }
134
332
  };
135
333
 
136
334
  bus.on('llm:text', onLlmText);
137
335
  bus.on('tts:sentence', onTtsSentence);
138
336
  bus.on('tool:called', onToolCalled);
337
+ bus.on('files:changed', onFilesChanged);
139
338
  bus.on('thinking', onThinking);
140
339
  bus.on('ready', onReady);
340
+ bus.on('session:end', onSessionEnd);
341
+ bus.on('session:dead', onSessionDead);
141
342
  bus.on('mqtt:error', onDisconnect);
343
+ bus.on('mqtt:disconnected', onDisconnect);
344
+ bus.on('mqtt:reconnecting', onReconnecting);
345
+ bus.on('mqtt:reconnected', onReconnected);
142
346
  bus.on('log:message', onLogMessage);
143
347
  bus.on('agent:progress', onAgentProgress);
348
+ bus.on('job:backgrounded', onJobBackgrounded);
349
+ bus.on('job:progress', onJobProgress);
350
+ bus.on('job:done', onJobDone);
351
+ bus.on('task:progress', onTaskProgress);
144
352
 
145
353
  return () => {
146
354
  bus.off('llm:text', onLlmText);
147
355
  bus.off('tts:sentence', onTtsSentence);
148
356
  bus.off('tool:called', onToolCalled);
357
+ bus.off('files:changed', onFilesChanged);
149
358
  bus.off('thinking', onThinking);
150
359
  bus.off('ready', onReady);
360
+ bus.off('session:end', onSessionEnd);
361
+ bus.off('session:dead', onSessionDead);
151
362
  bus.off('mqtt:error', onDisconnect);
363
+ bus.off('mqtt:disconnected', onDisconnect);
364
+ bus.off('mqtt:reconnecting', onReconnecting);
365
+ bus.off('mqtt:reconnected', onReconnected);
152
366
  bus.off('log:message', onLogMessage);
153
367
  bus.off('agent:progress', onAgentProgress);
368
+ bus.off('job:backgrounded', onJobBackgrounded);
369
+ bus.off('job:progress', onJobProgress);
370
+ bus.off('job:done', onJobDone);
371
+ bus.off('task:progress', onTaskProgress);
154
372
  if (ttsTimer) clearTimeout(ttsTimer);
373
+ if (activityTimer.current) clearTimeout(activityTimer.current);
374
+ if (taskTimer.current) clearTimeout(taskTimer.current);
155
375
  };
156
376
  }, []);
157
377
 
@@ -228,6 +448,16 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
228
448
  }]);
229
449
  return;
230
450
  }
451
+ if (cmd === '/files' || cmd === '/changes') {
452
+ const changes = extras.fileTracker?.getChanges() || [];
453
+ const body = changes.length
454
+ ? changes.map(c => ` ${c.action} ${c.path.replace(process.cwd() + '/', '')}${c.count > 1 ? ` (${c.count}x)` : ''}`).join('\n')
455
+ : 'No file changes this session';
456
+ setMessages(prev => [...prev, {
457
+ id: `sys-${Date.now()}`, role: 'system', content: `file changes:\n${body}`,
458
+ }]);
459
+ return;
460
+ }
231
461
  if (cmd === '/tools') {
232
462
  const tools = registry.list();
233
463
  const lines = tools.map(t => ` ${t.name} (${t.actions?.length || 0})`).join('\n');
@@ -257,10 +487,23 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
257
487
  const intent = parseIntent(text);
258
488
  if (intent.matched) {
259
489
  setThinking('tool_call');
260
- const result = await registry.call(intent.tool, intent.params);
490
+ // Route through the job layer: fast commands render inline as before,
491
+ // slow ones self-background (announced + confirmed by the job:* handlers)
492
+ // so the TUI never freezes on a long local action.
493
+ const dispatched = await jobManager.dispatch(
494
+ { tool: intent.tool, action: intent.action, label: `${intent.tool}.${intent.action || ''}` },
495
+ () => registry.call(intent.tool, intent.params),
496
+ );
261
497
  setThinking('idle');
498
+ if (!dispatched.done) return; // backgrounded — job:* handlers take over
499
+ if (dispatched.error) {
500
+ setMessages(prev => [...prev.slice(-maxMessages), {
501
+ id: `err-${Date.now()}`, role: 'system', content: `x ${intent.tool} failed: ${dispatched.error.message}`,
502
+ }]);
503
+ return;
504
+ }
262
505
  const { formatToolResult } = await import('../core/formatter.js');
263
- const formatted = formatToolResult(intent.tool, intent.action, result);
506
+ const formatted = formatToolResult(intent.tool, intent.action, dispatched.result);
264
507
  setMessages(prev => [...prev.slice(-maxMessages), {
265
508
  id: `tool-${Date.now()}`, role: 'tool',
266
509
  toolName: intent.tool, action: intent.action,
@@ -283,16 +526,14 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
283
526
  await new Promise(r => setTimeout(r, 500));
284
527
  }
285
528
 
286
- // Use agent controller for proper response handling
529
+ // Use agent controller for proper response handling.
530
+ // NOTE: XiaoZhi's actual reply is rendered live by the `tts:sentence` /
531
+ // `llm:text` handlers above, so we must NOT push result.result here or
532
+ // the final message would appear twice. The controller run is awaited
533
+ // purely to drive the think→act loop and the thinking indicator.
287
534
  try {
288
- const result = await agentController.run(text, { generatePlan: false });
535
+ await agentController.run(text, { generatePlan: false });
289
536
  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
537
  } catch (err) {
297
538
  setThinking('idle');
298
539
  setMessages(prev => [...prev.slice(-maxMessages), {
@@ -300,8 +541,15 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
300
541
  }]);
301
542
  }
302
543
  } else {
303
- // Fallback: send directly to XiaoZhi
304
- mqtt.sendText(text);
544
+ // Fallback: send directly to XiaoZhi (auto-reconnects if session died)
545
+ const sent = await mqtt.sendText(text);
546
+ setThinking('idle');
547
+ if (!sent) {
548
+ setMessages(prev => [...prev.slice(-maxMessages), {
549
+ id: `error-${Date.now()}`, role: 'system',
550
+ content: 'could not reach XiaoZhi — session lost, try again.',
551
+ }]);
552
+ }
305
553
  }
306
554
  }, [registry, mqtt, stats, session]);
307
555
 
@@ -309,8 +557,18 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
309
557
  const tools = registry.all();
310
558
  const actions = tools.reduce((s, t) => s + (t.actions?.length || 0), 0);
311
559
 
312
- // Calculate visible messages (scrollable window, max 12 rendered lines)
313
- const MAX_RENDER_LINES = 12;
560
+ // Dynamic chrome: base + the single-line widgets actually shown this frame,
561
+ // plus a reserve of 2 rows for the (possible) scroll-up/down hints. This
562
+ // guarantees banner + status + messages + input never exceed the terminal
563
+ // height, which is what caused the duplicated banner and vanishing chat.
564
+ const dynamicChrome =
565
+ BASE_CHROME +
566
+ (logLine ? 1 : 0) +
567
+ (thinking && thinking !== 'idle' ? 1 : 0) +
568
+ 2;
569
+ const MAX_RENDER_LINES = Math.max(3, rows - dynamicChrome);
570
+
571
+ // Calculate visible messages (scrollable window sized to terminal height)
314
572
  const totalMessages = messages.length;
315
573
  const endIdx = totalMessages - scrollOffset;
316
574
 
@@ -342,11 +600,13 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
342
600
  return React.createElement(Box, {
343
601
  flexDirection: 'column', width: '100%',
344
602
  },
345
- // Top: Banner + Status bar
603
+ // Top: Banner + Status bar (always visible / pinned)
346
604
  React.createElement(AsciiBanner, {
605
+ key: 'ascii-banner',
347
606
  version: config?.agent?.version,
348
607
  }),
349
608
  React.createElement(StatusBar, {
609
+ key: 'status-bar',
350
610
  connected, session: sessionId,
351
611
  }),
352
612
 
@@ -361,7 +621,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
361
621
  canScrollUp
362
622
  ? React.createElement(Box, { paddingLeft: 2 },
363
623
  React.createElement(Text, { dimColor: true, color: 'yellow' },
364
- `[${totalMessages - endIdx} more above | Shift+Up/Down or PgUp/PgDn to scroll]`
624
+ `[${startIdx} more above | Shift+Up/Down or PgUp/PgDn to scroll]`
365
625
  ),
366
626
  )
367
627
  : null,
@@ -24,49 +24,72 @@ async function readPkgVersion() {
24
24
  readPkgVersion();
25
25
 
26
26
  // ─── ASCII Banner ────────────────────────────────────────
27
- // Memoized — only re-renders when version prop changes
27
+ // Memoized — only re-renders when version prop changes.
28
+ // Uses useMemo to cache the element reference so Ink's reconciler
29
+ // skips re-rendering on every parent state change (fixes banner duplication).
28
30
  export const AsciiBanner = React.memo(function AsciiBanner({ version }) {
29
- const v = version || '0.0.0';
31
+ const v = version || _pkgVersion || '0.0.0';
32
+
33
+ const cat = [
34
+ ' /\\_/\\ ',
35
+ ' ( o.o ) ',
36
+ ' > ^ < ',
37
+ ' /| |\\ ',
38
+ ' (_| |_)',
39
+ ];
40
+ const info = [
41
+ 'P E L U L U - C L I',
42
+ `v${v}`,
43
+ 'the tiny coding companion',
44
+ 'powered by XiaoZhi',
45
+ '',
46
+ ];
47
+
30
48
  return React.createElement(Box, {
31
- flexDirection: 'column', paddingLeft: 1,
49
+ flexDirection: 'column', width: '100%',
50
+ borderStyle: 'single', borderColor: 'cyan',
51
+ paddingY: 0, paddingX: 0,
32
52
  },
33
- React.createElement(Box, null,
34
- React.createElement(Text, { color: 'cyan' }, ' /\\_/\\ '),
35
- React.createElement(Text, { color: 'cyan', bold: true }, 'PELULU-CLI'),
53
+ ...cat.map((line, i) =>
54
+ React.createElement(Box, { key: i, paddingLeft: 1 },
55
+ React.createElement(Text, { color: 'cyan' }, line),
56
+ React.createElement(Text, {
57
+ color: i === 0 ? 'cyan' : i === 1 ? 'gray' : i === 2 ? 'cyanBright' : 'gray',
58
+ bold: i === 0,
59
+ }, ' ' + (info[i] || '')),
60
+ )
61
+ ),
62
+ React.createElement(Box, { paddingLeft: 1 },
63
+ React.createElement(Text, { dimColor: true }, ' 18 tools • MCP protocol • agent mode • xiaozhi.me'),
36
64
  ),
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
65
  );
43
66
  });
44
67
 
45
68
  // ─── Status Bar ───────────────────────────────────────────
46
- // Memoizedonly re-renders when connected/session changes
69
+ // Uses useMemo to cache the element avoids re-rendering on parent state changes
47
70
  export const StatusBar = React.memo(function StatusBar({ connected, session }) {
48
71
  const statusDot = connected ? '●' : '○';
49
72
  const statusColor = connected ? 'green' : 'red';
50
73
  const sess = session ? session.slice(0, 8) : '---';
74
+ const label = connected ? ' online' : ' offline';
51
75
 
52
- return React.createElement(Box, {
76
+ const element = React.useMemo(() => React.createElement(Box, {
53
77
  borderStyle: 'single', borderColor: 'cyan', width: '100%',
54
78
  paddingX: 1, paddingY: 0,
55
79
  flexDirection: 'row', justifyContent: 'space-between',
56
80
  },
57
81
  React.createElement(Box, { flexDirection: 'row' },
58
- React.createElement(Text, { color: 'cyan', bold: true }, '🐱 PELULU '),
82
+ React.createElement(Text, { color: 'cyan', bold: true }, 'PELULU '),
59
83
  React.createElement(Text, { color: statusColor }, statusDot),
60
- React.createElement(Text, { color: statusColor, bold: connected },
61
- connected ? ' online' : ' offline'
62
- ),
84
+ React.createElement(Text, { color: statusColor, bold: connected }, label),
63
85
  ),
64
86
  React.createElement(Box, { flexDirection: 'row' },
65
87
  React.createElement(Text, { dimColor: true }, `session:${sess}`),
66
88
  React.createElement(Text, { dimColor: true }, ' '),
67
89
  React.createElement(Text, { dimColor: true }, 'xiaozhi.me'),
68
90
  ),
69
- );
91
+ ), [connected, sess]);
92
+ return element;
70
93
  });
71
94
 
72
95
  // ─── Strip Emojis ─────────────────────────────────────────
@@ -65,7 +65,7 @@ export async function renderAsciiBanner() {
65
65
  const info = [
66
66
  chalk.cyan.bold('P E L U L U - C L I'),
67
67
  chalk.gray(`v${version}`),
68
- chalk.cyan('coding companion'),
68
+ chalk.cyan('the tiny coding companion'),
69
69
  chalk.gray('powered by XiaoZhi'),
70
70
  '',
71
71
  ];