@redonvn/cli 0.1.10 → 0.1.11

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 (52) hide show
  1. package/dist/api/agent.d.ts +7 -0
  2. package/dist/api/agent.js +18 -0
  3. package/dist/api/agent.js.map +1 -0
  4. package/dist/api/auth.d.ts +5 -0
  5. package/dist/api/auth.js +14 -0
  6. package/dist/api/auth.js.map +1 -0
  7. package/dist/api/chat.d.ts +22 -0
  8. package/dist/api/chat.js +231 -0
  9. package/dist/api/chat.js.map +1 -0
  10. package/dist/auth/store.d.ts +7 -0
  11. package/dist/auth/store.js +17 -0
  12. package/dist/auth/store.js.map +1 -1
  13. package/dist/build-info.d.ts +2 -2
  14. package/dist/build-info.js +3 -3
  15. package/dist/build-info.js.map +1 -1
  16. package/dist/cli/commands/ask.js +1066 -324
  17. package/dist/cli/commands/ask.js.map +1 -1
  18. package/dist/cli/commands/login.d.ts +0 -1
  19. package/dist/cli/commands/login.js +7 -18
  20. package/dist/cli/commands/login.js.map +1 -1
  21. package/dist/cli/commands/logs.d.ts +4 -0
  22. package/dist/cli/commands/logs.js +66 -0
  23. package/dist/cli/commands/logs.js.map +1 -0
  24. package/dist/cli/commands/mcp.d.ts +3 -16
  25. package/dist/cli/commands/mcp.js +191 -74
  26. package/dist/cli/commands/mcp.js.map +1 -1
  27. package/dist/cli/commands/start.d.ts +7 -1
  28. package/dist/cli/commands/start.js +61 -2
  29. package/dist/cli/commands/start.js.map +1 -1
  30. package/dist/cli/commands/status.js +3 -5
  31. package/dist/cli/commands/status.js.map +1 -1
  32. package/dist/cli/commands/stop.d.ts +1 -0
  33. package/dist/cli/commands/stop.js +53 -0
  34. package/dist/cli/commands/stop.js.map +1 -0
  35. package/dist/cli/index.js +42 -3
  36. package/dist/cli/index.js.map +1 -1
  37. package/dist/cli-router/detect.d.ts +2 -1
  38. package/dist/cli-router/detect.js +10 -3
  39. package/dist/cli-router/detect.js.map +1 -1
  40. package/dist/config.d.ts +1 -1
  41. package/dist/daemon/tunnel.js +4 -3
  42. package/dist/daemon/tunnel.js.map +1 -1
  43. package/dist/mcp/chrome-launcher.d.ts +14 -0
  44. package/dist/mcp/chrome-launcher.js +114 -0
  45. package/dist/mcp/chrome-launcher.js.map +1 -0
  46. package/dist/mcp/client.d.ts +1 -0
  47. package/dist/mcp/client.js +9 -2
  48. package/dist/mcp/client.js.map +1 -1
  49. package/dist/mcp/config.d.ts +17 -9
  50. package/dist/mcp/config.js +134 -23
  51. package/dist/mcp/config.js.map +1 -1
  52. package/package.json +4 -2
@@ -1,37 +1,4 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  var __importDefault = (this && this.__importDefault) || function (mod) {
36
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
4
  };
@@ -45,11 +12,15 @@ exports.askCommand = askCommand;
45
12
  */
46
13
  const chalk_1 = __importDefault(require("chalk"));
47
14
  const readline_1 = __importDefault(require("readline"));
48
- const axios_1 = __importDefault(require("axios"));
49
15
  const store_1 = require("../../auth/store");
50
16
  const permissions_1 = require("../../auth/permissions");
51
17
  const permissions_2 = require("../../auth/permissions");
52
18
  const config_1 = require("../../config");
19
+ const agent_1 = require("../../api/agent");
20
+ const chat_1 = require("../../api/chat");
21
+ const config_2 = require("../../mcp/config");
22
+ const client_1 = require("../../mcp/client");
23
+ const chrome_launcher_1 = require("../../mcp/chrome-launcher");
53
24
  // ─── Session state ─────────────────────────────────────────────────────────────
54
25
  let currentThreadId = null;
55
26
  let currentAgentId = null;
@@ -63,16 +34,26 @@ const LOGO = [
63
34
  ' ██║ ██║███████╗██████╔╝██║ ██║██║',
64
35
  ' ╚═╝ ╚═╝╚══════╝╚═════╝ ╚═╝ ╚═╝╚═╝',
65
36
  ];
37
+ // Gradient #FF3333 → #FFCC99 top→bottom (logo) / #FFCC99 → #FF3333 for highlights
38
+ function gradientColor(step, total) {
39
+ const t = total <= 1 ? 0 : step / (total - 1);
40
+ const r = 0xFF;
41
+ const g = Math.round(0xCC - t * (0xCC - 0x33));
42
+ const b = Math.round(0x99 - t * (0x99 - 0x33));
43
+ return chalk_1.default.rgb(r, g, b);
44
+ }
45
+ // Single highlight color: bright orange-red, same family as logo
46
+ const HIGHLIGHT = chalk_1.default.rgb(0xFF, 0x66, 0x33);
66
47
  function printBanner(cfg) {
67
48
  console.log();
68
- for (const line of LOGO) {
69
- console.log(chalk_1.default.cyan(line));
70
- }
49
+ LOGO.forEach((line, i) => {
50
+ console.log(gradientColor(i, LOGO.length)(line));
51
+ });
71
52
  console.log();
72
53
  console.log(chalk_1.default.dim(' v' + config_1.CLI_VERSION) +
73
54
  ' ' +
74
55
  chalk_1.default.white(cfg.userName ?? cfg.userEmail ?? String(cfg.userId)));
75
- console.log(chalk_1.default.dim(' Type / to see commands, Ctrl-C to exit.'));
56
+ console.log(chalk_1.default.dim(' Type / for commands · ↑↓ navigate · Ctrl+Enter new line · Ctrl-C exit'));
76
57
  console.log();
77
58
  }
78
59
  // ─── Slash command list ────────────────────────────────────────────────────────
@@ -81,7 +62,8 @@ const COMMANDS = [
81
62
  { cmd: '/account', desc: 'Account info' },
82
63
  { cmd: '/agents', desc: 'List AI agents — select default' },
83
64
  { cmd: '/permissions', desc: 'View / toggle tool permissions on this machine' },
84
- { cmd: '/mcps', desc: 'List configured MCP servers' },
65
+ { cmd: '/mcps', desc: 'List MCP servers and their tools' },
66
+ { cmd: '/threads', desc: 'Browse & switch conversation threads' },
85
67
  { cmd: '/thread', desc: 'Show current conversation thread ID' },
86
68
  { cmd: '/new', desc: 'Start a new conversation thread' },
87
69
  { cmd: '/clear', desc: 'Clear screen' },
@@ -92,7 +74,7 @@ function printHelp() {
92
74
  console.log(chalk_1.default.bold(' Commands'));
93
75
  console.log();
94
76
  for (const { cmd, desc } of COMMANDS) {
95
- console.log(` ${chalk_1.default.cyan(cmd.padEnd(16))} ${chalk_1.default.dim(desc)}`);
77
+ console.log(` ${HIGHLIGHT(cmd.padEnd(16))} ${chalk_1.default.dim(desc)}`);
96
78
  }
97
79
  console.log();
98
80
  console.log(chalk_1.default.dim(' Type anything else to chat with the current agent.'));
@@ -110,16 +92,50 @@ function printAccount(cfg) {
110
92
  (cfg.expiresAt < Date.now() ? chalk_1.default.red(' (EXPIRED)') : ''));
111
93
  console.log();
112
94
  }
113
- // ─── /agents ──────────────────────────────────────────────────────────────────
114
- async function printAgentsInteractive(cfg) {
115
- process.stdout.write(chalk_1.default.dim(' Loading agents...\r'));
95
+ // ─── /agents — viewport TUI with lazy-load pagination ────────────────────────
96
+ //
97
+ // Layout (VIEWPORT_SIZE rows visible at a time):
98
+ //
99
+ // AI Agents (42) ↑↓ move · Enter select · 0 reset · Esc cancel
100
+ // Default (no agent): RedAI
101
+ //
102
+ // ❯ ● CLI Proxy ← focused row
103
+ // ○ Trung Hiếu
104
+ // ○ ...
105
+ // ── loading more... ── ← shown when fetching next page
106
+ //
107
+ // viewTop = first visible index in `agents[]`.
108
+ // cursor = absolute index in `agents[]`.
109
+ // When cursor reaches viewTop + VIEWPORT_SIZE - LOAD_AHEAD and more pages exist,
110
+ // fetch next page silently and append to `agents[]`.
111
+ const VIEWPORT_SIZE = 12;
112
+ const LOAD_AHEAD = 3; // trigger fetch when this many rows remain below cursor
113
+ const PAGE_SIZE = 20;
114
+ const AGENT_COL = 28;
115
+ function buildAgentRow(a, focused) {
116
+ const bullet = a.id === currentAgentId ? chalk_1.default.green('●') : chalk_1.default.dim('○');
117
+ const inactive = a.active ? '' : chalk_1.default.red(' (off)');
118
+ const pad = a.name.slice(0, AGENT_COL).padEnd(AGENT_COL);
119
+ const nameStr = focused
120
+ ? HIGHLIGHT.bold(pad)
121
+ : a.id === currentAgentId
122
+ ? chalk_1.default.bold.green(pad)
123
+ : chalk_1.default.white(pad);
124
+ return (focused ? HIGHLIGHT(' ❯ ') : ' ') + bullet + ' ' + nameStr + inactive;
125
+ }
126
+ async function interactiveAgents(cfg) {
127
+ process.stdout.write(chalk_1.default.dim('\n Loading agents...\r'));
116
128
  let agents = [];
129
+ let totalItems = 0;
130
+ let currentPage = 1;
131
+ let totalPages = 1;
132
+ let loadingMore = false;
117
133
  try {
118
- const res = await axios_1.default.get(`${cfg.apiBaseUrl}/api/v1/user/agents?pageSize=50&active=true`, {
119
- headers: buildHeaders(cfg),
120
- timeout: 10000,
121
- });
122
- agents = res.data?.data?.items ?? [];
134
+ const first = await (0, agent_1.fetchAgentsPage)(cfg, 1, PAGE_SIZE);
135
+ agents = first.items;
136
+ totalItems = first.meta.totalItems;
137
+ totalPages = first.meta.totalPages;
138
+ currentPage = 1;
123
139
  }
124
140
  catch {
125
141
  process.stdout.write(' \r');
@@ -133,41 +149,152 @@ async function printAgentsInteractive(cfg) {
133
149
  console.log();
134
150
  return;
135
151
  }
136
- console.log();
137
- console.log(chalk_1.default.bold(` AI Agents (${agents.length})`));
138
- console.log();
139
- agents.forEach((a, i) => {
140
- const current = a.id === currentAgentId;
141
- const bullet = current ? chalk_1.default.green('●') : chalk_1.default.dim('○');
142
- const name = current ? chalk_1.default.bold.green(a.name) : a.name;
143
- const type = a.typeEnum ? chalk_1.default.dim(` [${a.typeEnum}]`) : '';
144
- const active = a.active ? '' : chalk_1.default.red(' (inactive)');
145
- console.log(` ${bullet} ${String(i + 1).padStart(2)}. ${name}${type}${active}`);
146
- });
147
- console.log();
148
- console.log(chalk_1.default.dim(' Enter number to select agent, or press Enter to keep current.'));
149
- const answer = await new Promise((resolve) => {
150
- const tmp = readline_1.default.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
151
- process.stdout.write(chalk_1.default.dim(' › '));
152
- tmp.once('line', (line) => { tmp.close(); resolve(line.trim()); });
153
- });
154
- if (!answer) {
152
+ // cursor starts on currently selected agent, or 0
153
+ let cursor = Math.max(0, agents.findIndex(a => a.id === currentAgentId));
154
+ let viewTop = Math.max(0, Math.min(cursor, agents.length - VIEWPORT_SIZE));
155
+ // ── render helpers ─────────────────────────────────────────────────────────
156
+ // Number of rows currently shown (min of VIEWPORT_SIZE and available)
157
+ const viewLen = () => Math.min(VIEWPORT_SIZE, agents.length - viewTop);
158
+ // Header line (2 fixed lines) + viewLen data rows + footer (1 line) = full block
159
+ const HEADER_LINES = 3; // blank + title + hint
160
+ const FOOTER_LINES = 1; // blank line after list
161
+ const blockLines = () => HEADER_LINES + viewLen() + FOOTER_LINES;
162
+ const printBlock = () => {
163
+ const shown = viewLen();
155
164
  console.log();
156
- return;
157
- }
158
- const idx = parseInt(answer, 10) - 1;
159
- if (isNaN(idx) || idx < 0 || idx >= agents.length) {
160
- console.log(chalk_1.default.yellow(' Invalid selection.'));
165
+ console.log(chalk_1.default.bold(` AI Agents (${totalItems})`) +
166
+ chalk_1.default.dim(' ↑↓ move · Enter select · 0 reset · Esc cancel'));
167
+ console.log(chalk_1.default.dim(' Default (no agent): RedAI'));
168
+ for (let i = 0; i < shown; i++) {
169
+ const absIdx = viewTop + i;
170
+ console.log(buildAgentRow(agents[absIdx], absIdx === cursor));
171
+ }
161
172
  console.log();
162
- return;
163
- }
164
- const chosen = agents[idx];
165
- currentAgentId = chosen.id;
166
- currentAgentName = chosen.name;
167
- console.log(chalk_1.default.green(`\n ✓ Agent set to: ${chalk_1.default.bold(chosen.name)}`));
168
- console.log();
173
+ };
174
+ // Full redraw: erase existing block, reprint from top
175
+ const redraw = () => {
176
+ const bl = blockLines();
177
+ // Move up past entire block + erase to end of screen
178
+ process.stdout.write(`\x1B[${bl}A\r\x1B[J`);
179
+ printBlock();
180
+ };
181
+ // Repaint a single row in the viewport (absIdx = absolute index in agents[])
182
+ const repaintRow = (absIdx) => {
183
+ const relIdx = absIdx - viewTop;
184
+ if (relIdx < 0 || relIdx >= viewLen())
185
+ return;
186
+ // From bottom of block (after trailing blank line), move up to that row
187
+ const stepsUp = FOOTER_LINES + (viewLen() - 1 - relIdx) + 1; // +1 for blank
188
+ process.stdout.write(`\x1B[${stepsUp}A\r\x1B[2K`);
189
+ process.stdout.write(buildAgentRow(agents[absIdx], absIdx === cursor));
190
+ process.stdout.write(`\x1B[${stepsUp}B\r`);
191
+ };
192
+ printBlock();
193
+ // ── lazy-load next page ────────────────────────────────────────────────────
194
+ const maybeLoadMore = (resolve, handler) => {
195
+ if (loadingMore)
196
+ return;
197
+ if (currentPage >= totalPages)
198
+ return;
199
+ // trigger when cursor is within LOAD_AHEAD rows of the end of loaded list
200
+ if (cursor < agents.length - LOAD_AHEAD)
201
+ return;
202
+ loadingMore = true;
203
+ const nextPage = currentPage + 1;
204
+ // Show "loading" indicator on the last visible row
205
+ const lastVisAbs = viewTop + viewLen() - 1;
206
+ const stepsUp = FOOTER_LINES + 1; // blank + last row
207
+ process.stdout.write(`\x1B[${stepsUp}A\r\x1B[2K`);
208
+ process.stdout.write(chalk_1.default.dim(' ── loading more... ──'));
209
+ process.stdout.write(`\x1B[${stepsUp}B\r`);
210
+ (0, agent_1.fetchAgentsPage)(cfg, nextPage, PAGE_SIZE).then((page) => {
211
+ agents.push(...page.items);
212
+ currentPage = nextPage;
213
+ totalPages = page.meta.totalPages;
214
+ totalItems = page.meta.totalItems;
215
+ loadingMore = false;
216
+ void lastVisAbs;
217
+ redraw();
218
+ }).catch(() => {
219
+ loadingMore = false;
220
+ // restore overwritten row
221
+ repaintRow(lastVisAbs);
222
+ });
223
+ void resolve;
224
+ void handler;
225
+ };
226
+ // ── keypress loop ──────────────────────────────────────────────────────────
227
+ await new Promise((resolve) => {
228
+ const handler = (_char, key) => {
229
+ if (!key || loadingMore)
230
+ return;
231
+ // ↑ / k
232
+ if (key.name === 'up' || (key.name === 'k' && !key.ctrl)) {
233
+ if (cursor === 0)
234
+ return;
235
+ const prev = cursor--;
236
+ if (cursor < viewTop) {
237
+ viewTop--;
238
+ redraw();
239
+ }
240
+ else {
241
+ repaintRow(prev);
242
+ repaintRow(cursor);
243
+ }
244
+ return;
245
+ }
246
+ // ↓ / j
247
+ if (key.name === 'down' || (key.name === 'j' && !key.ctrl)) {
248
+ if (cursor === agents.length - 1 && currentPage >= totalPages)
249
+ return;
250
+ if (cursor < agents.length - 1) {
251
+ const prev = cursor++;
252
+ if (cursor >= viewTop + VIEWPORT_SIZE) {
253
+ viewTop++;
254
+ redraw();
255
+ }
256
+ else {
257
+ repaintRow(prev);
258
+ repaintRow(cursor);
259
+ }
260
+ maybeLoadMore(resolve, handler);
261
+ }
262
+ return;
263
+ }
264
+ // Enter — select
265
+ if (key.name === 'return') {
266
+ process.stdin.removeListener('keypress', handler);
267
+ const chosen = agents[cursor];
268
+ currentAgentId = chosen.id;
269
+ currentAgentName = chosen.name;
270
+ console.log(chalk_1.default.green(` ✓ Agent set to: ${chalk_1.default.bold(chosen.name)}`));
271
+ console.log();
272
+ resolve();
273
+ return;
274
+ }
275
+ // 0 — reset to default RedAI
276
+ if (key.name === '0') {
277
+ process.stdin.removeListener('keypress', handler);
278
+ currentAgentId = null;
279
+ currentAgentName = 'RedAI';
280
+ console.log(chalk_1.default.green(' ✓ Reset to default: RedAI'));
281
+ console.log();
282
+ resolve();
283
+ return;
284
+ }
285
+ // Esc / q — cancel
286
+ if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
287
+ process.stdin.removeListener('keypress', handler);
288
+ console.log(chalk_1.default.dim(' Cancelled. Agent unchanged.'));
289
+ console.log();
290
+ resolve();
291
+ return;
292
+ }
293
+ };
294
+ process.stdin.on('keypress', handler);
295
+ });
169
296
  }
170
- // ─── /permissions ─────────────────────────────────────────────────────────────
297
+ // ─── /permissions — interactive TUI ──────────────────────────────────────────
171
298
  const TOOL_DESC = {
172
299
  Read: 'Read files',
173
300
  Write: 'Create/overwrite files',
@@ -183,44 +310,513 @@ const TOOL_DESC = {
183
310
  McpCall: 'Call MCP tool',
184
311
  McpList: 'List MCP tools',
185
312
  };
186
- function printPermissions() {
313
+ // Render one permission row. cursorLine=true → this row is focused.
314
+ function permRow(name, enabled, focused) {
315
+ const checkbox = enabled ? chalk_1.default.green('[✓]') : chalk_1.default.dim('[ ]');
316
+ const nameStr = focused
317
+ ? HIGHLIGHT.bold(name.padEnd(13))
318
+ : chalk_1.default.white(name.padEnd(13));
319
+ const desc = focused
320
+ ? HIGHLIGHT.dim(TOOL_DESC[name])
321
+ : chalk_1.default.dim(TOOL_DESC[name]);
322
+ const cursor = focused ? HIGHLIGHT(' ❯ ') : ' ';
323
+ return cursor + checkbox + ' ' + nameStr + ' ' + desc;
324
+ }
325
+ async function interactivePermissions() {
187
326
  const perms = (0, permissions_1.loadPermissions)();
188
- console.log();
189
- console.log(chalk_1.default.bold(' Tool Permissions') + chalk_1.default.dim(' (edit: redai permission)'));
190
- console.log();
191
- for (const name of permissions_2.ALL_TOOL_NAMES) {
192
- const off = perms[name] === false;
193
- const icon = off ? chalk_1.default.red('✗ off') : chalk_1.default.green('✓ on ');
194
- console.log(` ${icon} ${chalk_1.default.bold(name.padEnd(13))} ${chalk_1.default.dim(TOOL_DESC[name])}`);
195
- }
196
- console.log();
327
+ // Build mutable state: enabled[i] mirrors perms for each tool
328
+ const enabled = permissions_2.ALL_TOOL_NAMES.map((n) => perms[n] !== false);
329
+ let cursor = 0;
330
+ const total = permissions_2.ALL_TOOL_NAMES.length;
331
+ // Print header + all rows
332
+ const printAll = () => {
333
+ console.log();
334
+ console.log(chalk_1.default.bold(' Tool Permissions') +
335
+ chalk_1.default.dim(' ↑↓ move · Space toggle · Enter save · Esc cancel'));
336
+ console.log();
337
+ for (let i = 0; i < total; i++) {
338
+ console.log(permRow(permissions_2.ALL_TOOL_NAMES[i], enabled[i], i === cursor));
339
+ }
340
+ console.log();
341
+ };
342
+ // Move cursor up N rows from current bottom of list, rewrite 1 row, return
343
+ const repaintRow = (idx) => {
344
+ // cursor is currently at bottom of list (after the trailing \n of printAll)
345
+ // We need absolute positioning: go up (total - idx) lines from bottom-of-list
346
+ const stepsUp = total - idx + 1; // +1 for the blank line after list
347
+ process.stdout.write(`\x1B[${stepsUp}A`); // move up
348
+ process.stdout.write('\r\x1B[2K'); // clear line
349
+ process.stdout.write(permRow(permissions_2.ALL_TOOL_NAMES[idx], enabled[idx], idx === cursor));
350
+ process.stdout.write(`\x1B[${stepsUp}B`); // move back down
351
+ process.stdout.write('\r');
352
+ };
353
+ printAll();
354
+ return new Promise((resolve) => {
355
+ const handler = (_char, key) => {
356
+ if (!key)
357
+ return;
358
+ // ↑ / k
359
+ if (key.name === 'up' || (key.name === 'k' && !key.ctrl)) {
360
+ if (cursor === 0)
361
+ return;
362
+ const prev = cursor;
363
+ cursor--;
364
+ repaintRow(prev);
365
+ repaintRow(cursor);
366
+ return;
367
+ }
368
+ // ↓ / j
369
+ if (key.name === 'down' || (key.name === 'j' && !key.ctrl)) {
370
+ if (cursor === total - 1)
371
+ return;
372
+ const prev = cursor;
373
+ cursor++;
374
+ repaintRow(prev);
375
+ repaintRow(cursor);
376
+ return;
377
+ }
378
+ // Space — toggle
379
+ if (key.name === 'space') {
380
+ enabled[cursor] = !enabled[cursor];
381
+ repaintRow(cursor);
382
+ return;
383
+ }
384
+ // Enter — save & exit
385
+ if (key.name === 'return') {
386
+ process.stdin.removeListener('keypress', handler);
387
+ const newPerms = {};
388
+ for (let i = 0; i < total; i++) {
389
+ newPerms[permissions_2.ALL_TOOL_NAMES[i]] = enabled[i];
390
+ }
391
+ (0, permissions_1.savePermissions)(newPerms);
392
+ const onCount = enabled.filter(Boolean).length;
393
+ const offCount = total - onCount;
394
+ console.log(chalk_1.default.green(' ✓ Saved.') +
395
+ chalk_1.default.dim(` ${onCount} enabled, ${offCount} disabled.`));
396
+ console.log();
397
+ resolve();
398
+ return;
399
+ }
400
+ // Esc / q / Ctrl-C — cancel
401
+ if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
402
+ process.stdin.removeListener('keypress', handler);
403
+ console.log(chalk_1.default.dim(' Cancelled. No changes saved.'));
404
+ console.log();
405
+ resolve();
406
+ return;
407
+ }
408
+ };
409
+ process.stdin.on('keypress', handler);
410
+ });
197
411
  }
198
- // ─── /mcps ────────────────────────────────────────────────────────────────────
199
- function printMcps() {
200
- try {
201
- // lazy import to avoid hard dep at top level
202
- // eslint-disable-next-line @typescript-eslint/no-require-imports
203
- const { loadMcpConfig } = require('../../mcp/config');
204
- const servers = loadMcpConfig();
205
- const names = Object.keys(servers);
412
+ // ─── /mcps — interactive TUI ──────────────────────────────────────────────────
413
+ //
414
+ // Keys:
415
+ // ↑↓ / j k navigate
416
+ // Space toggle enable/disable
417
+ // d remove server from config
418
+ // a open catalog to add servers
419
+ // Enter/t test selected server (spawn + list tools)
420
+ // Esc/q exit
421
+ async function interactiveMcps() {
422
+ let servers = (0, config_2.loadMcpConfig)();
423
+ let cursor = 0;
424
+ const getNames = () => Object.keys(servers);
425
+ const COL_NAME = 16;
426
+ const buildRow = (name, focused) => {
427
+ const cfg = servers[name];
428
+ const on = (0, config_2.isMcpEnabled)(cfg);
429
+ const statusIcon = on ? chalk_1.default.green('●') : chalk_1.default.dim('○');
430
+ const checkbox = on ? chalk_1.default.green('[on] ') : chalk_1.default.dim('[off]');
431
+ const nameStr = focused
432
+ ? HIGHLIGHT.bold(name.padEnd(COL_NAME))
433
+ : chalk_1.default.white(name.padEnd(COL_NAME));
434
+ const cmd = chalk_1.default.dim(`${cfg.command} ${(cfg.args ?? []).slice(0, 2).join(' ')}`);
435
+ const cur = focused ? HIGHLIGHT(' ❯ ') : ' ';
436
+ return cur + statusIcon + ' ' + checkbox + ' ' + nameStr + ' ' + cmd;
437
+ };
438
+ const printAll = (names) => {
206
439
  console.log();
207
- console.log(chalk_1.default.bold(' MCP Servers'));
440
+ console.log(chalk_1.default.bold(` MCP Servers (${names.length})`) +
441
+ chalk_1.default.dim(' ↑↓ move · Space on/off · d remove · a add · c chrome · Enter test · Esc exit'));
208
442
  console.log();
209
443
  if (names.length === 0) {
210
- console.log(chalk_1.default.dim(' No MCP servers configured.'));
211
- console.log(chalk_1.default.dim(' Run `redai mcp add` to configure one.'));
444
+ console.log(chalk_1.default.dim(' No servers configured. Press a to add from catalog.'));
212
445
  }
213
446
  else {
214
- for (const name of names) {
215
- console.log(` ${chalk_1.default.green('●')} ${name}`);
447
+ for (let i = 0; i < names.length; i++) {
448
+ console.log(buildRow(names[i], i === cursor));
216
449
  }
217
450
  }
218
451
  console.log();
452
+ };
453
+ const repaintRow = (idx, names) => {
454
+ const total = names.length;
455
+ if (total === 0)
456
+ return;
457
+ const stepsUp = total - idx + 1;
458
+ process.stdout.write(`\x1B[${stepsUp}A\r\x1B[2K`);
459
+ process.stdout.write(buildRow(names[idx], idx === cursor));
460
+ process.stdout.write(`\x1B[${stepsUp}B\r`);
461
+ };
462
+ const printStatus = (msg) => {
463
+ process.stdout.write('\r\x1B[2K' + msg + '\n');
464
+ };
465
+ printAll(getNames());
466
+ await new Promise((resolve) => {
467
+ const handler = async (_char, key) => {
468
+ if (!key)
469
+ return;
470
+ const names = getNames();
471
+ const total = names.length;
472
+ if (key.name === 'up' || (key.name === 'k' && !key.ctrl)) {
473
+ if (total === 0 || cursor === 0)
474
+ return;
475
+ const prev = cursor--;
476
+ repaintRow(prev, names);
477
+ repaintRow(cursor, names);
478
+ return;
479
+ }
480
+ if (key.name === 'down' || (key.name === 'j' && !key.ctrl)) {
481
+ if (total === 0 || cursor === total - 1)
482
+ return;
483
+ const prev = cursor++;
484
+ repaintRow(prev, names);
485
+ repaintRow(cursor, names);
486
+ return;
487
+ }
488
+ // Space — toggle enable/disable
489
+ if (key.name === 'space' && total > 0) {
490
+ const name = names[cursor];
491
+ const cfg = servers[name];
492
+ const nowEnabled = !(0, config_2.isMcpEnabled)(cfg);
493
+ servers[name] = { ...cfg, enabled: nowEnabled };
494
+ (0, config_2.saveMcpConfig)(servers);
495
+ repaintRow(cursor, names);
496
+ return;
497
+ }
498
+ // d — remove focused server
499
+ if (key.name === 'd' && total > 0) {
500
+ const name = names[cursor];
501
+ delete servers[name];
502
+ (0, config_2.saveMcpConfig)(servers);
503
+ if (cursor >= Object.keys(servers).length)
504
+ cursor = Math.max(0, Object.keys(servers).length - 1);
505
+ // Full redraw since row count changed
506
+ process.stdout.write(`\x1B[${total + 3}A`); // jump up past entire list + header + footer
507
+ process.stdout.write('\x1B[0J'); // erase from cursor to end
508
+ printAll(getNames());
509
+ return;
510
+ }
511
+ // a — add from catalog (pause this handler, run catalog TUI, resume)
512
+ if (key.name === 'a') {
513
+ process.stdin.removeListener('keypress', handler);
514
+ await addFromCatalogInline(servers);
515
+ // reload and redraw
516
+ servers = (0, config_2.loadMcpConfig)();
517
+ cursor = 0;
518
+ printAll(getNames());
519
+ process.stdin.on('keypress', handler);
520
+ return;
521
+ }
522
+ // Enter / t — test focused server
523
+ if ((key.name === 'return' || key.name === 't') && total > 0) {
524
+ const name = names[cursor];
525
+ const cfg = servers[name];
526
+ if (!(0, config_2.isMcpEnabled)(cfg)) {
527
+ printStatus(chalk_1.default.yellow(` "${name}" is disabled.`));
528
+ return;
529
+ }
530
+ printStatus(chalk_1.default.dim(` Connecting to ${name}...`));
531
+ try {
532
+ const client = await client_1.mcpClientPool.getClient(name);
533
+ const { tools } = await client.listTools();
534
+ printStatus(chalk_1.default.green(` ✓ ${name}: ${tools.length} tool(s) — ${tools.map(t => t.name).join(', ')}`));
535
+ await client_1.mcpClientPool.shutdown();
536
+ }
537
+ catch (err) {
538
+ printStatus(chalk_1.default.red(` ✗ ${name}: ${err instanceof Error ? err.message : String(err)}`));
539
+ }
540
+ return;
541
+ }
542
+ // c — launch Chrome with debug profile for chrome-devtools MCP
543
+ if (key.name === 'c') {
544
+ const already = await (0, chrome_launcher_1.isCdpAlive)();
545
+ if (already) {
546
+ printStatus(chalk_1.default.green(` ✓ Chrome CDP already active on port ${chrome_launcher_1.CDP_PORT}.`));
547
+ return;
548
+ }
549
+ printStatus(chalk_1.default.dim(' Launching Chrome with debug profile...'));
550
+ const result = await (0, chrome_launcher_1.launchChromeForDebug)();
551
+ if (result.ok) {
552
+ printStatus(chalk_1.default.green(` ✓ Chrome launched. CDP ready on port ${chrome_launcher_1.CDP_PORT}.`));
553
+ }
554
+ else {
555
+ printStatus(chalk_1.default.red(` ✗ ${result.error}`));
556
+ }
557
+ return;
558
+ }
559
+ // Esc / q — exit
560
+ if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
561
+ process.stdin.removeListener('keypress', handler);
562
+ resolve();
563
+ return;
564
+ }
565
+ };
566
+ process.stdin.on('keypress', handler);
567
+ });
568
+ }
569
+ // Inline catalog picker called from within /mcps TUI
570
+ async function addFromCatalogInline(existing) {
571
+ const items = config_2.MCP_CATALOG;
572
+ let cursor = 0;
573
+ const selected = new Array(items.length).fill(false);
574
+ const total = items.length;
575
+ const COL_NAME = 20;
576
+ const buildRow = (i, focused) => {
577
+ const entry = items[i];
578
+ const alreadyAdded = !!existing[entry.name];
579
+ const checked = selected[i];
580
+ const checkbox = alreadyAdded ? chalk_1.default.dim('[~]') : checked ? chalk_1.default.green('[✓]') : chalk_1.default.dim('[ ]');
581
+ const nameStr = alreadyAdded
582
+ ? chalk_1.default.dim(entry.name.padEnd(COL_NAME))
583
+ : focused ? HIGHLIGHT.bold(entry.name.padEnd(COL_NAME)) : chalk_1.default.white(entry.name.padEnd(COL_NAME));
584
+ const desc = focused && !alreadyAdded ? HIGHLIGHT.dim(entry.description) : chalk_1.default.dim(entry.description);
585
+ return (focused ? HIGHLIGHT(' ❯ ') : ' ') + checkbox + ' ' + nameStr + ' ' + desc;
586
+ };
587
+ const repaintRow = (idx) => {
588
+ const stepsUp = total - idx + 1;
589
+ process.stdout.write(`\x1B[${stepsUp}A\r\x1B[2K`);
590
+ process.stdout.write(buildRow(idx, idx === cursor));
591
+ process.stdout.write(`\x1B[${stepsUp}B\r`);
592
+ };
593
+ console.log();
594
+ console.log(chalk_1.default.bold(' Add from Catalog') + chalk_1.default.dim(' ↑↓ move · Space select · Enter install · Esc back'));
595
+ console.log(chalk_1.default.dim(' [~] = already installed'));
596
+ console.log();
597
+ for (let i = 0; i < total; i++)
598
+ console.log(buildRow(i, i === cursor));
599
+ console.log();
600
+ await new Promise((resolve) => {
601
+ const handler = (_char, key) => {
602
+ if (!key)
603
+ return;
604
+ if (key.name === 'up' || (key.name === 'k' && !key.ctrl)) {
605
+ if (cursor === 0)
606
+ return;
607
+ const prev = cursor--;
608
+ repaintRow(prev);
609
+ repaintRow(cursor);
610
+ return;
611
+ }
612
+ if (key.name === 'down' || (key.name === 'j' && !key.ctrl)) {
613
+ if (cursor === total - 1)
614
+ return;
615
+ const prev = cursor++;
616
+ repaintRow(prev);
617
+ repaintRow(cursor);
618
+ return;
619
+ }
620
+ if (key.name === 'space') {
621
+ if (!existing[items[cursor].name]) {
622
+ selected[cursor] = !selected[cursor];
623
+ repaintRow(cursor);
624
+ }
625
+ return;
626
+ }
627
+ if (key.name === 'return') {
628
+ process.stdin.removeListener('keypress', handler);
629
+ const toAdd = items.filter((_, i) => selected[i]);
630
+ for (const entry of toAdd) {
631
+ existing[entry.name] = entry.config;
632
+ console.log(chalk_1.default.green(` ✓ Added "${entry.name}"`));
633
+ if (entry.config.env)
634
+ console.log(chalk_1.default.yellow(` Needs: ${Object.keys(entry.config.env).join(', ')}`));
635
+ }
636
+ if (toAdd.length > 0)
637
+ (0, config_2.saveMcpConfig)(existing);
638
+ else
639
+ console.log(chalk_1.default.dim(' Nothing selected.'));
640
+ console.log();
641
+ resolve();
642
+ return;
643
+ }
644
+ if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
645
+ process.stdin.removeListener('keypress', handler);
646
+ resolve();
647
+ return;
648
+ }
649
+ };
650
+ process.stdin.on('keypress', handler);
651
+ });
652
+ }
653
+ // ─── /threads — viewport TUI with lazy-load pagination ───────────────────────
654
+ const THREAD_VIEWPORT = 12;
655
+ const THREAD_PAGE_SIZE = 20;
656
+ const THREAD_LOAD_AHEAD = 3;
657
+ const THREAD_COL_TITLE = 36;
658
+ function fmtThreadTime(ts) {
659
+ const d = new Date(ts);
660
+ const now = new Date();
661
+ const sameDay = d.toDateString() === now.toDateString();
662
+ if (sameDay)
663
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
664
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
665
+ }
666
+ function buildThreadRow(t, focused) {
667
+ const isCurrent = t.id === currentThreadId;
668
+ const bullet = isCurrent ? chalk_1.default.cyan('▶') : chalk_1.default.dim('○');
669
+ const title = (t.title ?? 'Untitled').slice(0, THREAD_COL_TITLE).padEnd(THREAD_COL_TITLE);
670
+ const time = chalk_1.default.dim(fmtThreadTime(t.updatedAt));
671
+ const cur = focused ? HIGHLIGHT(' ❯ ') : ' ';
672
+ const nameStr = focused
673
+ ? HIGHLIGHT.bold(title)
674
+ : isCurrent
675
+ ? chalk_1.default.bold.cyan(title)
676
+ : chalk_1.default.white(title);
677
+ return cur + bullet + ' ' + nameStr + ' ' + time;
678
+ }
679
+ async function interactiveThreads(cfg) {
680
+ process.stdout.write(chalk_1.default.dim('\n Loading threads...\r'));
681
+ let threads = [];
682
+ let totalItems = 0;
683
+ let currentPage = 1;
684
+ let totalPages = 1;
685
+ let loadingMore = false;
686
+ try {
687
+ const first = await (0, chat_1.fetchThreadsPage)(cfg, 1, THREAD_PAGE_SIZE);
688
+ threads = first.items;
689
+ totalItems = first.meta.totalItems;
690
+ totalPages = first.meta.totalPages;
219
691
  }
220
692
  catch {
221
- console.log(chalk_1.default.dim(' (MCP config unavailable)'));
693
+ process.stdout.write(' \r');
694
+ console.log(chalk_1.default.red(' ✗ Could not fetch threads.'));
695
+ console.log();
696
+ return;
697
+ }
698
+ process.stdout.write(' \r');
699
+ if (threads.length === 0) {
700
+ console.log(chalk_1.default.dim(' No threads found. Use /new to create one.'));
222
701
  console.log();
702
+ return;
223
703
  }
704
+ let cursor = Math.max(0, threads.findIndex(t => t.id === currentThreadId));
705
+ let viewTop = Math.max(0, Math.min(cursor, threads.length - THREAD_VIEWPORT));
706
+ const HEADER_LINES = 3;
707
+ const FOOTER_LINES = 1;
708
+ const viewLen = () => Math.min(THREAD_VIEWPORT, threads.length - viewTop);
709
+ const blockLines = () => HEADER_LINES + viewLen() + FOOTER_LINES;
710
+ const printBlock = () => {
711
+ const shown = viewLen();
712
+ console.log();
713
+ console.log(chalk_1.default.bold(` Threads (${totalItems})`) +
714
+ chalk_1.default.dim(' ↑↓ move · Enter switch · Esc cancel'));
715
+ console.log(chalk_1.default.dim(` Current: ${currentThreadId ? currentThreadId.slice(0, 8) + '…' : 'none'}`));
716
+ for (let i = 0; i < shown; i++) {
717
+ console.log(buildThreadRow(threads[viewTop + i], viewTop + i === cursor));
718
+ }
719
+ console.log();
720
+ };
721
+ const redraw = () => {
722
+ process.stdout.write(`\x1B[${blockLines()}A\r\x1B[J`);
723
+ printBlock();
724
+ };
725
+ const repaintRow = (absIdx) => {
726
+ const relIdx = absIdx - viewTop;
727
+ if (relIdx < 0 || relIdx >= viewLen())
728
+ return;
729
+ const stepsUp = FOOTER_LINES + (viewLen() - 1 - relIdx) + 1;
730
+ process.stdout.write(`\x1B[${stepsUp}A\r\x1B[2K`);
731
+ process.stdout.write(buildThreadRow(threads[absIdx], absIdx === cursor));
732
+ process.stdout.write(`\x1B[${stepsUp}B\r`);
733
+ };
734
+ printBlock();
735
+ const maybeLoadMore = () => {
736
+ if (loadingMore || currentPage >= totalPages)
737
+ return;
738
+ if (cursor < threads.length - THREAD_LOAD_AHEAD)
739
+ return;
740
+ loadingMore = true;
741
+ const nextPage = currentPage + 1;
742
+ const lastVisAbs = viewTop + viewLen() - 1;
743
+ const stepsUp = FOOTER_LINES + 1;
744
+ process.stdout.write(`\x1B[${stepsUp}A\r\x1B[2K`);
745
+ process.stdout.write(chalk_1.default.dim(' ── loading more... ──'));
746
+ process.stdout.write(`\x1B[${stepsUp}B\r`);
747
+ (0, chat_1.fetchThreadsPage)(cfg, nextPage, THREAD_PAGE_SIZE).then((page) => {
748
+ threads.push(...page.items);
749
+ currentPage = nextPage;
750
+ totalPages = page.meta.totalPages;
751
+ totalItems = page.meta.totalItems;
752
+ loadingMore = false;
753
+ void lastVisAbs;
754
+ redraw();
755
+ }).catch(() => {
756
+ loadingMore = false;
757
+ repaintRow(lastVisAbs);
758
+ });
759
+ };
760
+ await new Promise((resolve) => {
761
+ const handler = (_char, key) => {
762
+ if (!key || loadingMore)
763
+ return;
764
+ if (key.name === 'up' || (key.name === 'k' && !key.ctrl)) {
765
+ if (cursor === 0)
766
+ return;
767
+ const prev = cursor--;
768
+ if (cursor < viewTop) {
769
+ viewTop--;
770
+ redraw();
771
+ }
772
+ else {
773
+ repaintRow(prev);
774
+ repaintRow(cursor);
775
+ }
776
+ return;
777
+ }
778
+ if (key.name === 'down' || (key.name === 'j' && !key.ctrl)) {
779
+ if (cursor === threads.length - 1 && currentPage >= totalPages)
780
+ return;
781
+ if (cursor < threads.length - 1) {
782
+ const prev = cursor++;
783
+ if (cursor >= viewTop + THREAD_VIEWPORT) {
784
+ viewTop++;
785
+ redraw();
786
+ }
787
+ else {
788
+ repaintRow(prev);
789
+ repaintRow(cursor);
790
+ }
791
+ maybeLoadMore();
792
+ }
793
+ return;
794
+ }
795
+ if (key.name === 'return') {
796
+ process.stdin.removeListener('keypress', handler);
797
+ const chosen = threads[cursor];
798
+ currentThreadId = chosen.id;
799
+ // persist to session
800
+ const s = (0, store_1.loadSession)();
801
+ s.threads[cfg.apiBaseUrl] = chosen.id;
802
+ (0, store_1.saveSession)(s);
803
+ const title = chosen.title ?? 'Untitled';
804
+ console.log(chalk_1.default.cyan(` ✓ Switched to: ${chalk_1.default.bold(title.slice(0, 50))}`));
805
+ console.log(` ${chalk_1.default.dim('Thread ')} ${chosen.id}`);
806
+ console.log();
807
+ resolve();
808
+ return;
809
+ }
810
+ if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
811
+ process.stdin.removeListener('keypress', handler);
812
+ console.log(chalk_1.default.dim(' Cancelled. Thread unchanged.'));
813
+ console.log();
814
+ resolve();
815
+ return;
816
+ }
817
+ };
818
+ process.stdin.on('keypress', handler);
819
+ });
224
820
  }
225
821
  // ─── /thread ──────────────────────────────────────────────────────────────────
226
822
  function printThread() {
@@ -234,153 +830,171 @@ function printThread() {
234
830
  }
235
831
  console.log();
236
832
  }
237
- // ─── Create / reset thread ────────────────────────────────────────────────────
238
- async function createThread(cfg, title) {
239
- try {
240
- const res = await axios_1.default.post(`${cfg.apiBaseUrl}/api/v1/user/chat/threads`, { title }, { headers: buildHeaders(cfg), timeout: 10000 });
241
- return res.data?.data?.id ?? null;
242
- }
243
- catch {
244
- return null;
245
- }
246
- }
247
- // ─── Send message + stream response ───────────────────────────────────────────
248
- async function sendMessage(cfg, prompt) {
249
- // Ensure thread exists
833
+ // ─── Chat ─────────────────────────────────────────────────────────────────────
834
+ async function chat(cfg, prompt) {
250
835
  if (!currentThreadId) {
251
- process.stdout.write(chalk_1.default.dim(' Creating thread...'));
252
- const tid = await createThread(cfg, prompt.slice(0, 80));
253
- if (!tid) {
254
- process.stdout.write('\r' + ' '.repeat(30) + '\r');
255
- console.log(chalk_1.default.red(' ✗ Could not create thread. Check connection.'));
256
- console.log();
257
- return;
258
- }
259
- currentThreadId = tid;
260
- process.stdout.write('\r' + ' '.repeat(30) + '\r');
836
+ console.log(chalk_1.default.yellow('\n No active thread. Run /new to create one first.\n'));
837
+ return;
261
838
  }
262
- // Send message get runId
263
- process.stdout.write(chalk_1.default.dim(` ${currentAgentName}...`));
264
- let runId = null;
839
+ // Animated spinner: cycles until first delta or error
840
+ const FRAMES = ['⠋', '⠙', '⠹', '', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
841
+ let frameIdx = 0;
842
+ let spinnerActive = false;
843
+ let spinnerTimer = null;
844
+ const startSpinner = (label) => {
845
+ spinnerActive = true;
846
+ frameIdx = 0;
847
+ process.stdout.write(chalk_1.default.dim(` ${FRAMES[0]} ${label}...`));
848
+ spinnerTimer = setInterval(() => {
849
+ if (!spinnerActive)
850
+ return;
851
+ frameIdx = (frameIdx + 1) % FRAMES.length;
852
+ process.stdout.write(`\r ${chalk_1.default.dim(FRAMES[frameIdx] + ' ' + label + '...')}`);
853
+ }, 80);
854
+ };
855
+ const stopSpinner = () => {
856
+ if (!spinnerActive)
857
+ return; // idempotent — don't erase content after spinner stopped
858
+ if (spinnerTimer) {
859
+ clearInterval(spinnerTimer);
860
+ spinnerTimer = null;
861
+ }
862
+ spinnerActive = false;
863
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
864
+ };
865
+ startSpinner(currentAgentName);
866
+ let runId;
265
867
  try {
266
- const body = {
267
- contentBlocks: [{ type: 'text', text: prompt }],
268
- agentMode: 'FAST',
269
- };
270
- if (currentAgentId)
271
- body.agentId = currentAgentId;
272
- const res = await axios_1.default.post(`${cfg.apiBaseUrl}/api/v1/user/chat/threads/${currentThreadId}/messages`, body, { headers: buildHeaders(cfg), timeout: 30000 });
273
- runId = res.data?.data?.runId ?? null;
274
- if (!runId)
275
- throw new Error('No runId returned');
868
+ const result = await (0, chat_1.sendMessage)(cfg, currentThreadId, prompt, currentAgentId);
869
+ runId = result.runId;
276
870
  }
277
871
  catch (err) {
278
- process.stdout.write('\r' + ' '.repeat(40) + '\r');
279
- const msg = axios_1.default.isAxiosError(err) && err.response
280
- ? `${err.response.status}: ${JSON.stringify(err.response.data)}`
281
- : err instanceof Error ? err.message : String(err);
282
- console.log(chalk_1.default.red(` ✗ ${msg}`));
872
+ stopSpinner();
873
+ console.log(chalk_1.default.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
283
874
  console.log();
284
875
  return;
285
876
  }
286
- process.stdout.write('\r' + ' '.repeat(40) + '\r');
287
- // Stream SSE response
288
- console.log();
289
- process.stdout.write(chalk_1.default.dim(' '));
877
+ // Spinner keeps running while waiting for first SSE event
878
+ let firstEvent = true;
879
+ let streamErrored = false;
880
+ const stopSpinnerAndBegin = () => {
881
+ if (!firstEvent)
882
+ return;
883
+ firstEvent = false;
884
+ stopSpinner();
885
+ console.log();
886
+ process.stdout.write(chalk_1.default.dim(' '));
887
+ };
888
+ const streamCbs = {
889
+ onDelta: (text) => {
890
+ stopSpinnerAndBegin();
891
+ process.stdout.write(text);
892
+ },
893
+ onToolCall: (toolName, toolArgs) => {
894
+ stopSpinnerAndBegin();
895
+ const argsStr = JSON.stringify(toolArgs, null, 2)
896
+ .split('\n')
897
+ .map((l, i) => (i === 0 ? l : ' ' + l))
898
+ .join('\n');
899
+ process.stdout.write(`\n\n ${chalk_1.default.dim('⚙')} ${chalk_1.default.cyan(toolName)} ${chalk_1.default.dim(argsStr)}\n `);
900
+ },
901
+ onToolResult: (_toolName, result) => {
902
+ const preview = typeof result === 'string'
903
+ ? result.slice(0, 120)
904
+ : JSON.stringify(result ?? '').slice(0, 120);
905
+ process.stdout.write(`\n ${chalk_1.default.dim('→')} ${chalk_1.default.dim(preview)}${(preview.length >= 120 ? chalk_1.default.dim('…') : '')}\n `);
906
+ },
907
+ onToolInterrupt: (toolName, toolArgs) => {
908
+ stopSpinnerAndBegin();
909
+ const argsStr = JSON.stringify(toolArgs);
910
+ process.stdout.write(`\n\n ${chalk_1.default.yellow('⏸')} ${chalk_1.default.yellow(`Tool call requires approval: ${toolName}`)} ${chalk_1.default.dim(argsStr)}\n `);
911
+ },
912
+ onError: (msg) => {
913
+ stopSpinnerAndBegin();
914
+ streamErrored = true;
915
+ process.stdout.write(`\n\n ${chalk_1.default.red('✗')} ${chalk_1.default.red(msg)}\n`);
916
+ },
917
+ };
290
918
  try {
291
- await streamResponse(cfg, currentThreadId, runId);
919
+ await (0, chat_1.streamResponse)(cfg, currentThreadId, runId, streamCbs);
292
920
  }
293
921
  catch (err) {
294
- console.log(chalk_1.default.red(`\n ✗ Stream error: ${err instanceof Error ? err.message : String(err)}`));
922
+ stopSpinnerAndBegin();
923
+ if (!streamErrored) {
924
+ console.log(chalk_1.default.red(`\n ✗ Stream error: ${err instanceof Error ? err.message : String(err)}`));
925
+ }
926
+ }
927
+ finally {
928
+ // Ensure spinner cleared even if stream ended without any events
929
+ stopSpinner();
295
930
  }
296
931
  console.log('\n');
297
932
  }
298
- async function streamResponse(cfg, threadId, runId) {
299
- const url = `${cfg.apiBaseUrl}/api/v1/user/chat/threads/${threadId}/stream/${runId}`;
300
- // Use undici for proper SSE streaming
301
- const { fetch: undiciFetch } = await Promise.resolve().then(() => __importStar(require('undici')));
302
- const res = await undiciFetch(url, {
303
- headers: { ...buildHeaders(cfg), Accept: 'text/event-stream' },
304
- });
305
- if (!res.body)
306
- return;
307
- const reader = res.body.getReader();
308
- const decoder = new TextDecoder();
309
- let buf = '';
310
- let lastType = '';
311
- while (true) {
312
- const { done, value } = await reader.read();
313
- if (done)
314
- break;
315
- buf += decoder.decode(value, { stream: true });
316
- const lines = buf.split('\n');
317
- buf = lines.pop() ?? '';
318
- for (const line of lines) {
319
- if (!line.startsWith('data: '))
320
- continue;
321
- const raw = line.slice(6).trim();
322
- if (!raw || raw === '[DONE]')
323
- continue;
324
- try {
325
- const evt = JSON.parse(raw);
326
- // Text delta → print inline
327
- const delta = evt.delta ?? evt.text ?? evt.content ?? '';
328
- if (delta) {
329
- // First chunk after non-text event → newline indent
330
- if (lastType !== 'text')
331
- process.stdout.write('\n ');
332
- process.stdout.write(delta);
333
- lastType = 'text';
334
- }
335
- else if (evt.type === 'done' || evt.status === 'completed') {
336
- break;
337
- }
338
- else if (evt.error) {
339
- console.log(chalk_1.default.red(`\n ✗ ${evt.error}`));
340
- break;
341
- }
342
- }
343
- catch {
344
- // Non-JSON SSE line — ignore
345
- }
346
- }
933
+ const sugg = {
934
+ matched: [],
935
+ selectedIdx: 0,
936
+ renderedLines: 0,
937
+ buffer: '',
938
+ };
939
+ const COL_CMD = 18;
940
+ // Strip ANSI codes to get visual width
941
+ function visibleLen(s) {
942
+ return s.replace(/\x1B\[[0-9;]*m/g, '').length;
943
+ }
944
+ // Build a single suggestion row string
945
+ function buildRow(c, selected) {
946
+ const cmdPadded = c.cmd.padEnd(COL_CMD);
947
+ if (selected) {
948
+ return ' ' + HIGHLIGHT.bold(cmdPadded) + ' ' + HIGHLIGHT(c.desc);
347
949
  }
348
- reader.releaseLock();
950
+ return ' ' + chalk_1.default.white(cmdPadded) + ' ' + chalk_1.default.dim(c.desc);
349
951
  }
350
- // ─── Auth headers ─────────────────────────────────────────────────────────────
351
- function buildHeaders(cfg) {
352
- return {
353
- Authorization: `Bearer ${cfg.cliToken}`,
354
- 'Content-Type': 'application/json',
355
- // workspace scope — required by guards
356
- ...(cfg.workspaceId ? { 'x-workspace-id': cfg.workspaceId } : {}),
357
- };
952
+ // Write all ANSI output in a single syscall to prevent mid-frame flicker
953
+ function flush(s) {
954
+ process.stdout.write(s);
358
955
  }
359
- // ─── Inline suggestion bar ────────────────────────────────────────────────────
360
- const CMD_NAMES = COMMANDS.map((c) => c.cmd); // ['/help', '/account', ...]
361
- /**
362
- * Xóa suggestion line hiện tại (1 dòng phía trên cursor) rồi in lại.
363
- * Chỉ gọi khi đang trong slash-mode.
364
- */
365
- function renderSuggestions(typed) {
366
- const query = typed.toLowerCase();
367
- const matched = CMD_NAMES.filter((c) => c.startsWith(query));
368
- // Xóa dòng suggestion cũ (cursor đang ở cuối prompt line → lên 1 dòng xóa)
369
- process.stdout.write('\x1B[1A\x1B[2K'); // lên 1 dòng, xóa dòng
370
- if (matched.length === 0) {
371
- process.stdout.write(chalk_1.default.dim(' no matching command') + '\n');
372
- }
373
- else {
374
- const parts = matched.map((c) => {
375
- // Bold phần đã gõ, dim phần còn lại
376
- const rest = c.slice(query.length);
377
- return chalk_1.default.bold.cyan(query) + chalk_1.default.dim(rest);
378
- });
379
- process.stdout.write(' ' + parts.join(chalk_1.default.dim(' · ')) + '\n');
956
+ function buildEraseBlock() {
957
+ if (sugg.renderedLines === 0)
958
+ return '';
959
+ let s = '';
960
+ for (let i = 0; i < sugg.renderedLines; i++)
961
+ s += '\x1B[1B\x1B[2K';
962
+ s += `\x1B[${sugg.renderedLines}A`;
963
+ return s;
964
+ }
965
+ function eraseSuggestions() {
966
+ if (sugg.renderedLines === 0)
967
+ return;
968
+ flush(buildEraseBlock());
969
+ sugg.renderedLines = 0;
970
+ }
971
+ // Partial update — only rewrite the 2 rows that changed (prev + next selected).
972
+ // Batched into ONE flush call to eliminate flicker.
973
+ function patchSelection(prevIdx, nextIdx) {
974
+ if (sugg.matched.length === 0)
975
+ return;
976
+ const buf = sugg.buffer;
977
+ const promptCursorCol = visibleLen('redai › ' + buf);
978
+ // From prompt line, go to prevIdx row, rewrite it
979
+ const toPrev = prevIdx + 1;
980
+ const toNext = nextIdx - prevIdx; // relative move after rewriting prev
981
+ const backUp = nextIdx + 1; // lines to go back up to prompt
982
+ let out = `\x1B[${toPrev}B\r\x1B[2K` + buildRow(sugg.matched[prevIdx], false);
983
+ if (toNext !== 0) {
984
+ out += `\x1B[${Math.abs(toNext)}${toNext > 0 ? 'B' : 'A'}`;
380
985
  }
986
+ out += `\r\x1B[2K` + buildRow(sugg.matched[nextIdx], true);
987
+ out += `\x1B[${backUp}A\r\x1B[${promptCursorCol}C`;
988
+ flush(out);
381
989
  }
382
- function clearSuggestions() {
383
- process.stdout.write('\x1B[1A\x1B[2K'); // xóa dòng suggestion
990
+ function moveSuggestion(dir) {
991
+ if (sugg.matched.length === 0)
992
+ return;
993
+ const prev = sugg.selectedIdx;
994
+ const next = (prev + dir + sugg.matched.length) % sugg.matched.length;
995
+ sugg.selectedIdx = next;
996
+ // Only patch the 2 changed rows — no full redraw, no flicker
997
+ patchSelection(prev, next);
384
998
  }
385
999
  // ─── Main REPL ────────────────────────────────────────────────────────────────
386
1000
  async function askCommand() {
@@ -393,139 +1007,267 @@ async function askCommand() {
393
1007
  console.error(chalk_1.default.red('Session expired. Run `redai login` again.'));
394
1008
  process.exit(1);
395
1009
  }
1010
+ // Restore last thread for this server from session cache
1011
+ const session = (0, store_1.loadSession)();
1012
+ if (session.threads[cfg.apiBaseUrl]) {
1013
+ currentThreadId = session.threads[cfg.apiBaseUrl];
1014
+ }
396
1015
  process.stdout.write('\x1Bc');
397
1016
  printBanner(cfg);
398
- // Enable keypress events trên stdin
1017
+ if (currentThreadId) {
1018
+ console.log(` ${chalk_1.default.dim('Thread ')} ${chalk_1.default.dim(currentThreadId)}`);
1019
+ console.log();
1020
+ }
399
1021
  readline_1.default.emitKeypressEvents(process.stdin);
400
1022
  if (process.stdin.isTTY)
401
1023
  process.stdin.setRawMode(true);
402
- const PROMPT = '\n' + chalk_1.default.cyan('redai') + chalk_1.default.dim(' › ');
403
- let buffer = ''; // ký tự user đang gõ
404
- let slashMode = false; // đang trong chế độ gõ slash command
405
- let busy = false; // đang xử async (gửi message)
406
- // In 1 dòng trống làm chỗ cho suggestion, rồi in prompt
407
- const printPromptWithSuggestionSlot = () => {
408
- process.stdout.write('\n'); // dòng suggestion (trống ban đầu)
409
- process.stdout.write(PROMPT);
1024
+ let buffer = '';
1025
+ let slashMode = false;
1026
+ let busy = false;
1027
+ let inputLines = 1; // terminal rows the current input block occupies
1028
+ const PROMPT_PREFIX = chalk_1.default.cyan('redai') + chalk_1.default.dim(' ');
1029
+ const CONT_PREFIX = chalk_1.default.dim(' ... '); // continuation prefix for Shift+Enter lines
1030
+ const PROMPT_VIS = 'redai ';
1031
+ const CONT_VIS = ' ... ';
1032
+ // Build one display string per logical line in buffer
1033
+ const buildInputLines = () => buffer.split('\n').map((part, i) => i === 0 ? PROMPT_PREFIX + part : CONT_PREFIX + part);
1034
+ // Erase the current input block + any suggestion rows, then reprint everything
1035
+ // in a single flush() call to avoid flicker.
1036
+ // After the call, cursor sits at end of the last input line.
1037
+ const reprintInputBlock = (withSuggestions = false) => {
1038
+ const lines = buildInputLines();
1039
+ const newCount = lines.length;
1040
+ const lastLine = lines[newCount - 1];
1041
+ const lastVis = newCount === 1
1042
+ ? visibleLen(PROMPT_VIS + buffer.split('\n')[0])
1043
+ : visibleLen(CONT_VIS + buffer.split('\n')[newCount - 1]);
1044
+ let out = '';
1045
+ // Move up to top of old input block (cursor is on last input line)
1046
+ if (inputLines > 1)
1047
+ out += `\x1B[${inputLines - 1}A`;
1048
+ // Erase from here to bottom of screen (wipes input + old suggestions)
1049
+ out += '\r\x1B[J';
1050
+ // Write all input lines
1051
+ out += lines.join('\n');
1052
+ inputLines = newCount;
1053
+ if (withSuggestions && sugg.matched.length > 0) {
1054
+ const rows = sugg.matched.map((c, i) => buildRow(c, i === sugg.selectedIdx));
1055
+ out += '\n' + rows.join('\n');
1056
+ // Jump back up past suggestion rows to last input line
1057
+ out += `\x1B[${rows.length}A`;
1058
+ sugg.renderedLines = rows.length;
1059
+ }
1060
+ else {
1061
+ sugg.renderedLines = 0;
1062
+ }
1063
+ // Position cursor at end of last input line
1064
+ out += `\r\x1B[${lastVis}C`;
1065
+ // lastLine is used only for its length — already captured above
1066
+ void lastLine;
1067
+ flush(out);
1068
+ };
1069
+ const printPrompt = () => {
1070
+ buffer = '';
1071
+ inputLines = 1;
1072
+ process.stdout.write('\n' + PROMPT_PREFIX);
1073
+ };
1074
+ printPrompt();
1075
+ // Execute whatever command is currently in buffer (or selected suggestion)
1076
+ const executeInput = async (input) => {
1077
+ if (!input.startsWith('/')) {
1078
+ busy = true;
1079
+ await chat(cfg, input);
1080
+ busy = false;
1081
+ printPrompt();
1082
+ return;
1083
+ }
1084
+ const cmd = input.split(/\s+/)[0].toLowerCase();
1085
+ busy = true;
1086
+ switch (cmd) {
1087
+ case '/':
1088
+ case '/help':
1089
+ printHelp();
1090
+ break;
1091
+ case '/account':
1092
+ printAccount(cfg);
1093
+ break;
1094
+ case '/agents':
1095
+ await interactiveAgents(cfg);
1096
+ break;
1097
+ case '/permissions':
1098
+ await interactivePermissions();
1099
+ break;
1100
+ case '/mcps':
1101
+ await interactiveMcps();
1102
+ break;
1103
+ case '/threads':
1104
+ await interactiveThreads(cfg);
1105
+ break;
1106
+ case '/thread':
1107
+ printThread();
1108
+ break;
1109
+ case '/new': {
1110
+ process.stdout.write(chalk_1.default.dim('\n Creating thread...'));
1111
+ try {
1112
+ currentThreadId = await (0, chat_1.createThread)(cfg, 'New conversation');
1113
+ const s = (0, store_1.loadSession)();
1114
+ s.threads[cfg.apiBaseUrl] = currentThreadId;
1115
+ (0, store_1.saveSession)(s);
1116
+ process.stdout.write('\r' + ' '.repeat(30) + '\r');
1117
+ console.log(chalk_1.default.green(' ✓ New thread created'));
1118
+ console.log(` ${chalk_1.default.dim('Thread ')} ${currentThreadId}`);
1119
+ console.log();
1120
+ }
1121
+ catch (err) {
1122
+ process.stdout.write('\r' + ' '.repeat(30) + '\r');
1123
+ const msg = err instanceof Error ? err.message : String(err);
1124
+ console.log(chalk_1.default.red(' ✗ Could not create thread:'), chalk_1.default.dim(msg));
1125
+ console.log();
1126
+ }
1127
+ break;
1128
+ }
1129
+ case '/clear':
1130
+ process.stdout.write('\x1Bc');
1131
+ printBanner(cfg);
1132
+ break;
1133
+ case '/exit':
1134
+ case '/quit':
1135
+ console.log(chalk_1.default.dim('\n Bye!\n'));
1136
+ process.exit(0);
1137
+ default:
1138
+ console.log(chalk_1.default.yellow(`\n Unknown command: ${cmd}`));
1139
+ console.log(chalk_1.default.dim(' Type /help to see available commands.\n'));
1140
+ }
1141
+ busy = false;
1142
+ printPrompt();
410
1143
  };
411
- printPromptWithSuggestionSlot();
412
1144
  process.stdin.on('keypress', async (char, key) => {
413
1145
  if (busy)
414
1146
  return;
415
- // ── Ctrl-C / Ctrl-D → exit ───────────────────────────────────────────────
1147
+ // ── Ctrl-C / Ctrl-D ───────────────────────────────────────────────────────
416
1148
  if (key?.ctrl && (key.name === 'c' || key.name === 'd')) {
1149
+ if (sugg.renderedLines > 0)
1150
+ eraseSuggestions();
417
1151
  console.log(chalk_1.default.dim('\n\n Bye!\n'));
418
1152
  process.exit(0);
419
1153
  }
1154
+ // ── Arrow Up ──────────────────────────────────────────────────────────────
1155
+ if (key?.name === 'up') {
1156
+ if (slashMode && sugg.matched.length > 0)
1157
+ moveSuggestion(-1);
1158
+ return;
1159
+ }
1160
+ // ── Arrow Down ────────────────────────────────────────────────────────────
1161
+ if (key?.name === 'down') {
1162
+ if (slashMode && sugg.matched.length > 0)
1163
+ moveSuggestion(1);
1164
+ return;
1165
+ }
1166
+ // ── Escape — close suggestions, keep buffer ───────────────────────────────
1167
+ if (key?.name === 'escape') {
1168
+ if (slashMode) {
1169
+ slashMode = false;
1170
+ sugg.matched = [];
1171
+ sugg.selectedIdx = 0;
1172
+ sugg.buffer = '';
1173
+ reprintInputBlock();
1174
+ }
1175
+ return;
1176
+ }
1177
+ // ── Ctrl+Enter (0x0A, name='enter') — insert newline ─────────────────────
1178
+ // Windows Terminal sends 0x0A for Ctrl+Enter, distinct from Enter (0x0D)
1179
+ if (key?.name === 'enter') {
1180
+ if (slashMode)
1181
+ return;
1182
+ buffer += '\n';
1183
+ reprintInputBlock();
1184
+ return;
1185
+ }
420
1186
  // ── Backspace ────────────────────────────────────────────────────────────
421
1187
  if (key?.name === 'backspace') {
422
1188
  if (buffer.length === 0)
423
1189
  return;
424
1190
  buffer = buffer.slice(0, -1);
425
- // Xóa 1 ký tự trên stdout
426
- process.stdout.write('\b \b');
427
1191
  if (slashMode) {
428
1192
  if (buffer === '') {
429
1193
  slashMode = false;
430
- clearSuggestions();
431
- }
432
- else {
433
- renderSuggestions(buffer);
434
- process.stdout.write(buffer); // reprint buffer sau khi render lại
435
1194
  }
1195
+ // reprint input + updated suggestions in one shot
1196
+ sugg.matched = buffer === ''
1197
+ ? []
1198
+ : COMMANDS.filter(c => c.cmd.startsWith(buffer.toLowerCase()));
1199
+ sugg.selectedIdx = 0;
1200
+ sugg.buffer = buffer;
1201
+ reprintInputBlock(buffer.length > 0);
436
1202
  }
1203
+ else {
1204
+ reprintInputBlock();
1205
+ }
1206
+ return;
1207
+ }
1208
+ // ── Tab — fill selected suggestion ───────────────────────────────────────
1209
+ if (key?.name === 'tab' && slashMode && sugg.matched.length > 0) {
1210
+ buffer = sugg.matched[sugg.selectedIdx].cmd;
1211
+ slashMode = false;
1212
+ sugg.matched = [];
1213
+ sugg.selectedIdx = 0;
1214
+ sugg.buffer = '';
1215
+ sugg.renderedLines = 0;
1216
+ reprintInputBlock();
437
1217
  return;
438
1218
  }
439
- // ── Enter ────────────────────────────────────────────────────────────────
1219
+ // ── Enter — submit ────────────────────────────────────────────────────────
440
1220
  if (key?.name === 'return') {
1221
+ // Pick selected suggestion if list is open
1222
+ if (slashMode && sugg.matched.length > 0) {
1223
+ buffer = sugg.matched[sugg.selectedIdx].cmd;
1224
+ }
441
1225
  const input = buffer.trim();
1226
+ // Erase input block + suggestions
1227
+ let out = '';
1228
+ if (inputLines > 1)
1229
+ out += `\x1B[${inputLines - 1}A`;
1230
+ out += '\r\x1B[J';
442
1231
  buffer = '';
443
1232
  slashMode = false;
444
- process.stdout.write('\n'); // xuống dòng sau input
1233
+ inputLines = 1;
1234
+ sugg.matched = [];
1235
+ sugg.selectedIdx = 0;
1236
+ sugg.renderedLines = 0;
445
1237
  if (!input) {
446
- printPromptWithSuggestionSlot();
447
- return;
448
- }
449
- if (input.startsWith('/')) {
450
- const cmd = input.split(' ')[0].toLowerCase();
451
- busy = true;
452
- switch (cmd) {
453
- case '/':
454
- case '/help':
455
- printHelp();
456
- break;
457
- case '/account':
458
- printAccount(cfg);
459
- break;
460
- case '/agents':
461
- await printAgentsInteractive(cfg);
462
- break;
463
- case '/permissions':
464
- printPermissions();
465
- break;
466
- case '/mcps':
467
- printMcps();
468
- break;
469
- case '/thread':
470
- printThread();
471
- break;
472
- case '/new':
473
- currentThreadId = null;
474
- console.log(chalk_1.default.dim('\n Thread reset — next message starts a new conversation.\n'));
475
- break;
476
- case '/clear':
477
- process.stdout.write('\x1Bc');
478
- printBanner(cfg);
479
- break;
480
- case '/exit':
481
- case '/quit':
482
- console.log(chalk_1.default.dim('\n Bye!\n'));
483
- process.exit(0);
484
- default:
485
- console.log(chalk_1.default.yellow(`\n Unknown command: ${cmd}`));
486
- console.log(chalk_1.default.dim(' Type / to see available commands.\n'));
487
- }
488
- busy = false;
489
- printPromptWithSuggestionSlot();
1238
+ // Empty submit — just reprint prompt in-place, no blank line
1239
+ flush(out + PROMPT_PREFIX);
490
1240
  return;
491
1241
  }
492
- // Regular prompt gửi lên backend
493
- busy = true;
494
- await sendMessage(cfg, input);
495
- busy = false;
496
- printPromptWithSuggestionSlot();
497
- return;
498
- }
499
- // ── Tab → autocomplete lệnh đầu tiên match ───────────────────────────────
500
- if (key?.name === 'tab' && slashMode) {
501
- const query = buffer.toLowerCase();
502
- const match = CMD_NAMES.find((c) => c.startsWith(query));
503
- if (match) {
504
- // Xóa buffer hiện tại trên stdout
505
- process.stdout.write('\b \b'.repeat(buffer.length));
506
- buffer = match;
507
- clearSuggestions();
508
- slashMode = false;
509
- process.stdout.write(buffer);
510
- }
1242
+ // Echo submitted text collapsed to single line, then newline
1243
+ const displayText = input.replace(/\n/g, ' ↵ ');
1244
+ out += PROMPT_PREFIX + displayText + '\n';
1245
+ flush(out);
1246
+ await executeInput(input);
511
1247
  return;
512
1248
  }
513
- // ── tự thường ─────────────────────────────────────────────────────────
1249
+ // ── Regular character ─────────────────────────────────────────────────────
514
1250
  if (!char || char.length !== 1)
515
1251
  return;
516
1252
  buffer += char;
517
- process.stdout.write(char);
518
- // Bắt đầu slash mode khi '/' đầu tiên
1253
+ if (slashMode) {
1254
+ // update matched list then reprint input + suggestions together
1255
+ sugg.matched = COMMANDS.filter(c => c.cmd.startsWith(buffer.toLowerCase()));
1256
+ sugg.selectedIdx = 0;
1257
+ sugg.buffer = buffer;
1258
+ reprintInputBlock(true);
1259
+ return;
1260
+ }
519
1261
  if (buffer === '/') {
520
1262
  slashMode = true;
521
- renderSuggestions('/');
522
- process.stdout.write('/'); // reprint sau suggestion
1263
+ sugg.matched = [...COMMANDS];
1264
+ sugg.selectedIdx = 0;
1265
+ sugg.buffer = buffer;
1266
+ reprintInputBlock(true);
523
1267
  return;
524
1268
  }
525
- if (slashMode) {
526
- renderSuggestions(buffer);
527
- process.stdout.write(buffer); // reprint toàn bộ buffer sau suggestion
528
- }
1269
+ // Plain text — just append char without full reprint (no flicker)
1270
+ process.stdout.write(char);
529
1271
  });
530
1272
  await new Promise(() => undefined);
531
1273
  }