pelulu-cli 1.0.5 → 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.
@@ -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,10 +5,11 @@
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';
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,14 +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 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);
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);
37
53
  }, []);
38
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
+
39
62
  // ─── Enable Ink mode for logger ──────────────────
40
63
  useEffect(() => {
41
64
  setInkMode(true, bus);
@@ -49,6 +72,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
49
72
  useEffect(() => {
50
73
  const onLlmText = (text) => {
51
74
  setThinking('idle');
75
+ if (activityTimer.current) { clearTimeout(activityTimer.current); activityTimer.current = null; }
52
76
  // Strip emojis to check if there's real content
53
77
  const clean = text.replace(/\p{Emoji_Presentation}/gu, '').replace(/\p{Extended_Pictographic}/gu, '').trim();
54
78
  if (!clean) return; // skip emoji-only LLM responses (TTS will carry the real text)
@@ -58,6 +82,8 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
58
82
  };
59
83
 
60
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; }
61
87
  setMessages(prev => [...prev.slice(-maxMessages), {
62
88
  id: `tool-${Date.now()}`, role: 'tool',
63
89
  toolName: name, action: args?.action,
@@ -71,14 +97,164 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
71
97
  }]);
72
98
  };
73
99
 
74
- 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
+ };
75
134
  const onReady = () => {
76
135
  setConnected(true);
77
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;
78
141
  };
79
- const onDisconnect = () => {
142
+ const onDisconnect = (err) => {
80
143
  setConnected(false);
81
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.`);
82
258
  };
83
259
 
84
260
  // Single log line that updates in place
@@ -99,6 +275,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
99
275
  const onTtsSentence = (text) => {
100
276
  ttsBuffer += text;
101
277
  setThinking('idle');
278
+ if (activityTimer.current) { clearTimeout(activityTimer.current); activityTimer.current = null; }
102
279
  // Debounce: wait for all TTS sentences to arrive, then display
103
280
  if (ttsTimer) clearTimeout(ttsTimer);
104
281
  ttsTimer = setTimeout(() => {
@@ -117,23 +294,84 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
117
294
  }, 500);
118
295
  };
119
296
 
297
+ // Agent progress events
298
+ const onAgentProgress = ({ state, message, tool, action }) => {
299
+ if (state === 'tool') {
300
+ agentBusyRef.current = true;
301
+ setThinking('tool_call');
302
+ setLogLine(`[TOOL] ${tool}.${action || ''}...`);
303
+ armWatchdog();
304
+ } else if (state === 'tool_done') {
305
+ agentBusyRef.current = true;
306
+ setThinking('thinking');
307
+ setLogLine(`[TOOL] done, waiting...`);
308
+ armWatchdog();
309
+ } else if (state === 'receiving') {
310
+ agentBusyRef.current = true;
311
+ setLogLine(`[LLM] ${message}`);
312
+ armWatchdog();
313
+ } else if (state === 'thinking') {
314
+ agentBusyRef.current = true;
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();
325
+ } else if (state === 'timeout') {
326
+ agentBusyRef.current = false;
327
+ setThinking('idle');
328
+ clearWatchdog();
329
+ setLogLine(`[WARN] ${message}`);
330
+ pushSystem(`timeout: ${message}`);
331
+ }
332
+ };
333
+
120
334
  bus.on('llm:text', onLlmText);
121
335
  bus.on('tts:sentence', onTtsSentence);
122
336
  bus.on('tool:called', onToolCalled);
337
+ bus.on('files:changed', onFilesChanged);
123
338
  bus.on('thinking', onThinking);
124
339
  bus.on('ready', onReady);
340
+ bus.on('session:end', onSessionEnd);
341
+ bus.on('session:dead', onSessionDead);
125
342
  bus.on('mqtt:error', onDisconnect);
343
+ bus.on('mqtt:disconnected', onDisconnect);
344
+ bus.on('mqtt:reconnecting', onReconnecting);
345
+ bus.on('mqtt:reconnected', onReconnected);
126
346
  bus.on('log:message', onLogMessage);
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);
127
352
 
128
353
  return () => {
129
354
  bus.off('llm:text', onLlmText);
130
355
  bus.off('tts:sentence', onTtsSentence);
131
356
  bus.off('tool:called', onToolCalled);
357
+ bus.off('files:changed', onFilesChanged);
132
358
  bus.off('thinking', onThinking);
133
359
  bus.off('ready', onReady);
360
+ bus.off('session:end', onSessionEnd);
361
+ bus.off('session:dead', onSessionDead);
134
362
  bus.off('mqtt:error', onDisconnect);
363
+ bus.off('mqtt:disconnected', onDisconnect);
364
+ bus.off('mqtt:reconnecting', onReconnecting);
365
+ bus.off('mqtt:reconnected', onReconnected);
135
366
  bus.off('log:message', onLogMessage);
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);
136
372
  if (ttsTimer) clearTimeout(ttsTimer);
373
+ if (activityTimer.current) clearTimeout(activityTimer.current);
374
+ if (taskTimer.current) clearTimeout(taskTimer.current);
137
375
  };
138
376
  }, []);
139
377
 
@@ -171,6 +409,16 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
171
409
 
172
410
  // ─── Handle Submit ────────────────────────────────
173
411
  const handleSubmit = useCallback(async (text) => {
412
+ // Limit input to 70 chars (XiaoZhi limit)
413
+ const MAX_INPUT = 70;
414
+ if (text.length > MAX_INPUT) {
415
+ setMessages(prev => [...prev.slice(-maxMessages), {
416
+ id: `warn-${Date.now()}`, role: 'system',
417
+ content: `⚠️ Input too long (${text.length}/${MAX_INPUT} chars)`,
418
+ }]);
419
+ return;
420
+ }
421
+
174
422
  // Add user message
175
423
  setMessages(prev => [...prev.slice(-maxMessages), {
176
424
  id: `user-${Date.now()}`, role: 'user', content: text,
@@ -200,6 +448,16 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
200
448
  }]);
201
449
  return;
202
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
+ }
203
461
  if (cmd === '/tools') {
204
462
  const tools = registry.list();
205
463
  const lines = tools.map(t => ` ${t.name} (${t.actions?.length || 0})`).join('\n');
@@ -229,10 +487,23 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
229
487
  const intent = parseIntent(text);
230
488
  if (intent.matched) {
231
489
  setThinking('tool_call');
232
- 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
+ );
233
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
+ }
234
505
  const { formatToolResult } = await import('../core/formatter.js');
235
- const formatted = formatToolResult(intent.tool, intent.action, result);
506
+ const formatted = formatToolResult(intent.tool, intent.action, dispatched.result);
236
507
  setMessages(prev => [...prev.slice(-maxMessages), {
237
508
  id: `tool-${Date.now()}`, role: 'tool',
238
509
  toolName: intent.tool, action: intent.action,
@@ -244,31 +515,99 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
244
515
  }
245
516
  } catch {}
246
517
 
247
- // Send to XiaoZhi
518
+ // Send to XiaoZhi via Agent Controller (if available) or direct
248
519
  setThinking('thinking');
249
- mqtt.sendText(text);
520
+
521
+ const agentController = extras?.agentController;
522
+ if (agentController) {
523
+ // Reset agent if it's stuck in running state
524
+ if (agentController.isRunning) {
525
+ agentController.abort();
526
+ await new Promise(r => setTimeout(r, 500));
527
+ }
528
+
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.
534
+ try {
535
+ await agentController.run(text, { generatePlan: false });
536
+ setThinking('idle');
537
+ } catch (err) {
538
+ setThinking('idle');
539
+ setMessages(prev => [...prev.slice(-maxMessages), {
540
+ id: `error-${Date.now()}`, role: 'system', content: `Error: ${err.message}`,
541
+ }]);
542
+ }
543
+ } else {
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
+ }
553
+ }
250
554
  }, [registry, mqtt, stats, session]);
251
555
 
252
556
  // ─── Render ───────────────────────────────────────
253
557
  const tools = registry.all();
254
558
  const actions = tools.reduce((s, t) => s + (t.actions?.length || 0), 0);
255
559
 
256
- // Calculate visible messages (scrollable window)
257
- const availableRows = getAvailableRows();
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)
258
572
  const totalMessages = messages.length;
259
573
  const endIdx = totalMessages - scrollOffset;
260
- const startIdx = Math.max(0, endIdx - availableRows);
574
+
575
+ // Count lines backwards from endIdx to fit within MAX_RENDER_LINES
576
+ let usedLines = 0;
577
+ let startIdx = endIdx;
578
+ for (let i = endIdx - 1; i >= 0; i--) {
579
+ const msg = messages[i];
580
+ let msgLines = 1;
581
+ if (msg.role === 'assistant' && msg.content) {
582
+ const w = (process.stdout.columns || 80) - 6;
583
+ const words = stripEmojis(msg.content).split(/\s+/);
584
+ let lineLen = 0;
585
+ msgLines = 1;
586
+ for (const word of words) {
587
+ if (lineLen + word.length + 1 > w && lineLen > 0) { msgLines++; lineLen = word.length; }
588
+ else { lineLen += word.length + (lineLen > 0 ? 1 : 0); }
589
+ }
590
+ }
591
+ if (usedLines + msgLines > MAX_RENDER_LINES) break;
592
+ usedLines += msgLines;
593
+ startIdx = i;
594
+ }
595
+
261
596
  const visibleMessages = messages.slice(startIdx, endIdx);
262
- const canScrollUp = scrollOffset < totalMessages - 1;
597
+ const canScrollUp = startIdx > 0;
263
598
  const canScrollDown = scrollOffset > 0;
264
599
 
265
600
  return React.createElement(Box, {
266
601
  flexDirection: 'column', width: '100%',
267
602
  },
268
- // Top: Status bar
603
+ // Top: Banner + Status bar (always visible / pinned)
604
+ React.createElement(AsciiBanner, {
605
+ key: 'ascii-banner',
606
+ version: config?.agent?.version,
607
+ }),
269
608
  React.createElement(StatusBar, {
609
+ key: 'status-bar',
270
610
  connected, session: sessionId,
271
- version: config?.agent?.version,
272
611
  }),
273
612
 
274
613
  // Log status line (single line, auto-updating, auto-hiding)
@@ -282,7 +621,7 @@ export function createApp({ registry, mqtt, stats, session, bus, config, extras
282
621
  canScrollUp
283
622
  ? React.createElement(Box, { paddingLeft: 2 },
284
623
  React.createElement(Text, { dimColor: true, color: 'yellow' },
285
- `[${totalMessages - endIdx} more above | Shift+Up/Down or PgUp/PgDn to scroll]`
624
+ `[${startIdx} more above | Shift+Up/Down or PgUp/PgDn to scroll]`
286
625
  ),
287
626
  )
288
627
  : null,