@redonvn/cli 0.1.9 → 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 +1102 -279
  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 printAgents(cfg, rl) {
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,44 +149,152 @@ async function printAgents(cfg, rl) {
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
- // Pause readline and wait for selection inline
150
- rl.pause();
151
- const answer = await new Promise((resolve) => {
152
- const tmp = readline_1.default.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
153
- process.stdout.write(chalk_1.default.dim(' › '));
154
- tmp.once('line', (line) => { tmp.close(); resolve(line.trim()); });
155
- });
156
- rl.resume();
157
- 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();
158
164
  console.log();
159
- return;
160
- }
161
- const idx = parseInt(answer, 10) - 1;
162
- if (isNaN(idx) || idx < 0 || idx >= agents.length) {
163
- 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
+ }
164
172
  console.log();
165
- return;
166
- }
167
- const chosen = agents[idx];
168
- currentAgentId = chosen.id;
169
- currentAgentName = chosen.name;
170
- console.log(chalk_1.default.green(`\n ✓ Agent set to: ${chalk_1.default.bold(chosen.name)}`));
171
- 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
+ });
172
296
  }
173
- // ─── /permissions ─────────────────────────────────────────────────────────────
297
+ // ─── /permissions — interactive TUI ──────────────────────────────────────────
174
298
  const TOOL_DESC = {
175
299
  Read: 'Read files',
176
300
  Write: 'Create/overwrite files',
@@ -186,44 +310,513 @@ const TOOL_DESC = {
186
310
  McpCall: 'Call MCP tool',
187
311
  McpList: 'List MCP tools',
188
312
  };
189
- 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() {
190
326
  const perms = (0, permissions_1.loadPermissions)();
191
- console.log();
192
- console.log(chalk_1.default.bold(' Tool Permissions') + chalk_1.default.dim(' (edit: redai permission)'));
193
- console.log();
194
- for (const name of permissions_2.ALL_TOOL_NAMES) {
195
- const off = perms[name] === false;
196
- const icon = off ? chalk_1.default.red('✗ off') : chalk_1.default.green('✓ on ');
197
- console.log(` ${icon} ${chalk_1.default.bold(name.padEnd(13))} ${chalk_1.default.dim(TOOL_DESC[name])}`);
198
- }
199
- 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
+ });
200
411
  }
201
- // ─── /mcps ────────────────────────────────────────────────────────────────────
202
- function printMcps() {
203
- try {
204
- // lazy import to avoid hard dep at top level
205
- // eslint-disable-next-line @typescript-eslint/no-require-imports
206
- const { loadMcpConfig } = require('../../mcp/config');
207
- const servers = loadMcpConfig();
208
- 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) => {
209
439
  console.log();
210
- 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'));
211
442
  console.log();
212
443
  if (names.length === 0) {
213
- console.log(chalk_1.default.dim(' No MCP servers configured.'));
214
- 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.'));
215
445
  }
216
446
  else {
217
- for (const name of names) {
218
- console.log(` ${chalk_1.default.green('●')} ${name}`);
447
+ for (let i = 0; i < names.length; i++) {
448
+ console.log(buildRow(names[i], i === cursor));
219
449
  }
220
450
  }
221
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;
222
691
  }
223
692
  catch {
224
- 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.'));
225
701
  console.log();
702
+ return;
226
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
+ });
227
820
  }
228
821
  // ─── /thread ──────────────────────────────────────────────────────────────────
229
822
  function printThread() {
@@ -237,127 +830,171 @@ function printThread() {
237
830
  }
238
831
  console.log();
239
832
  }
240
- // ─── Create / reset thread ────────────────────────────────────────────────────
241
- async function createThread(cfg, title) {
242
- try {
243
- const res = await axios_1.default.post(`${cfg.apiBaseUrl}/api/v1/user/chat/threads`, { title }, { headers: buildHeaders(cfg), timeout: 10000 });
244
- return res.data?.data?.id ?? null;
245
- }
246
- catch {
247
- return null;
248
- }
249
- }
250
- // ─── Send message + stream response ───────────────────────────────────────────
251
- async function sendMessage(cfg, prompt) {
252
- // Ensure thread exists
833
+ // ─── Chat ─────────────────────────────────────────────────────────────────────
834
+ async function chat(cfg, prompt) {
253
835
  if (!currentThreadId) {
254
- process.stdout.write(chalk_1.default.dim(' Creating thread...'));
255
- const tid = await createThread(cfg, prompt.slice(0, 80));
256
- if (!tid) {
257
- process.stdout.write('\r' + ' '.repeat(30) + '\r');
258
- console.log(chalk_1.default.red(' ✗ Could not create thread. Check connection.'));
259
- console.log();
260
- return;
261
- }
262
- currentThreadId = tid;
263
- 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;
264
838
  }
265
- // Send message get runId
266
- process.stdout.write(chalk_1.default.dim(` ${currentAgentName}...`));
267
- 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;
268
867
  try {
269
- const body = {
270
- contentBlocks: [{ type: 'text', text: prompt }],
271
- agentMode: 'FAST',
272
- };
273
- if (currentAgentId)
274
- body.agentId = currentAgentId;
275
- const res = await axios_1.default.post(`${cfg.apiBaseUrl}/api/v1/user/chat/threads/${currentThreadId}/messages`, body, { headers: buildHeaders(cfg), timeout: 30000 });
276
- runId = res.data?.data?.runId ?? null;
277
- if (!runId)
278
- throw new Error('No runId returned');
868
+ const result = await (0, chat_1.sendMessage)(cfg, currentThreadId, prompt, currentAgentId);
869
+ runId = result.runId;
279
870
  }
280
871
  catch (err) {
281
- process.stdout.write('\r' + ' '.repeat(40) + '\r');
282
- const msg = axios_1.default.isAxiosError(err) && err.response
283
- ? `${err.response.status}: ${JSON.stringify(err.response.data)}`
284
- : err instanceof Error ? err.message : String(err);
285
- console.log(chalk_1.default.red(` ✗ ${msg}`));
872
+ stopSpinner();
873
+ console.log(chalk_1.default.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
286
874
  console.log();
287
875
  return;
288
876
  }
289
- process.stdout.write('\r' + ' '.repeat(40) + '\r');
290
- // Stream SSE response
291
- console.log();
292
- 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
+ };
293
918
  try {
294
- await streamResponse(cfg, currentThreadId, runId);
919
+ await (0, chat_1.streamResponse)(cfg, currentThreadId, runId, streamCbs);
295
920
  }
296
921
  catch (err) {
297
- 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();
298
930
  }
299
931
  console.log('\n');
300
932
  }
301
- async function streamResponse(cfg, threadId, runId) {
302
- const url = `${cfg.apiBaseUrl}/api/v1/user/chat/threads/${threadId}/stream/${runId}`;
303
- // Use undici for proper SSE streaming
304
- const { fetch: undiciFetch } = await Promise.resolve().then(() => __importStar(require('undici')));
305
- const res = await undiciFetch(url, {
306
- headers: { ...buildHeaders(cfg), Accept: 'text/event-stream' },
307
- });
308
- if (!res.body)
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);
949
+ }
950
+ return ' ' + chalk_1.default.white(cmdPadded) + ' ' + chalk_1.default.dim(c.desc);
951
+ }
952
+ // Write all ANSI output in a single syscall to prevent mid-frame flicker
953
+ function flush(s) {
954
+ process.stdout.write(s);
955
+ }
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)
309
967
  return;
310
- const reader = res.body.getReader();
311
- const decoder = new TextDecoder();
312
- let buf = '';
313
- let lastType = '';
314
- while (true) {
315
- const { done, value } = await reader.read();
316
- if (done)
317
- break;
318
- buf += decoder.decode(value, { stream: true });
319
- const lines = buf.split('\n');
320
- buf = lines.pop() ?? '';
321
- for (const line of lines) {
322
- if (!line.startsWith('data: '))
323
- continue;
324
- const raw = line.slice(6).trim();
325
- if (!raw || raw === '[DONE]')
326
- continue;
327
- try {
328
- const evt = JSON.parse(raw);
329
- // Text delta → print inline
330
- const delta = evt.delta ?? evt.text ?? evt.content ?? '';
331
- if (delta) {
332
- // First chunk after non-text event → newline indent
333
- if (lastType !== 'text')
334
- process.stdout.write('\n ');
335
- process.stdout.write(delta);
336
- lastType = 'text';
337
- }
338
- else if (evt.type === 'done' || evt.status === 'completed') {
339
- break;
340
- }
341
- else if (evt.error) {
342
- console.log(chalk_1.default.red(`\n ✗ ${evt.error}`));
343
- break;
344
- }
345
- }
346
- catch {
347
- // Non-JSON SSE line — ignore
348
- }
349
- }
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'}`;
350
985
  }
351
- reader.releaseLock();
986
+ out += `\r\x1B[2K` + buildRow(sugg.matched[nextIdx], true);
987
+ out += `\x1B[${backUp}A\r\x1B[${promptCursorCol}C`;
988
+ flush(out);
352
989
  }
353
- // ─── Auth headers ─────────────────────────────────────────────────────────────
354
- function buildHeaders(cfg) {
355
- return {
356
- Authorization: `Bearer ${cfg.cliToken}`,
357
- 'Content-Type': 'application/json',
358
- // workspace scope — required by guards
359
- ...(cfg.workspaceId ? { 'x-workspace-id': cfg.workspaceId } : {}),
360
- };
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);
361
998
  }
362
999
  // ─── Main REPL ────────────────────────────────────────────────────────────────
363
1000
  async function askCommand() {
@@ -370,81 +1007,267 @@ async function askCommand() {
370
1007
  console.error(chalk_1.default.red('Session expired. Run `redai login` again.'));
371
1008
  process.exit(1);
372
1009
  }
373
- process.stdout.write('\x1Bc'); // clear screen
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
+ }
1015
+ process.stdout.write('\x1Bc');
374
1016
  printBanner(cfg);
375
- const rl = readline_1.default.createInterface({
376
- input: process.stdin,
377
- output: process.stdout,
378
- terminal: true,
379
- prompt: '\n' + chalk_1.default.cyan('redai') + chalk_1.default.dim(' › '),
380
- });
381
- rl.prompt();
382
- rl.on('line', async (raw) => {
383
- const input = raw.trim();
384
- if (!input) {
385
- rl.prompt();
1017
+ if (currentThreadId) {
1018
+ console.log(` ${chalk_1.default.dim('Thread ')} ${chalk_1.default.dim(currentThreadId)}`);
1019
+ console.log();
1020
+ }
1021
+ readline_1.default.emitKeypressEvents(process.stdin);
1022
+ if (process.stdin.isTTY)
1023
+ process.stdin.setRawMode(true);
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();
1143
+ };
1144
+ process.stdin.on('keypress', async (char, key) => {
1145
+ if (busy)
386
1146
  return;
1147
+ // ── Ctrl-C / Ctrl-D ───────────────────────────────────────────────────────
1148
+ if (key?.ctrl && (key.name === 'c' || key.name === 'd')) {
1149
+ if (sugg.renderedLines > 0)
1150
+ eraseSuggestions();
1151
+ console.log(chalk_1.default.dim('\n\n Bye!\n'));
1152
+ process.exit(0);
387
1153
  }
388
- // ── / show command list ────────────────────────────────────────────────
389
- if (input === '/') {
390
- printHelp();
391
- rl.prompt();
1154
+ // ── Arrow Up ──────────────────────────────────────────────────────────────
1155
+ if (key?.name === 'up') {
1156
+ if (slashMode && sugg.matched.length > 0)
1157
+ moveSuggestion(-1);
392
1158
  return;
393
1159
  }
394
- // ── Slash commands ────────────────────────────────────────────────────────
395
- if (input.startsWith('/')) {
396
- const cmd = input.split(' ')[0].toLowerCase();
397
- rl.pause();
398
- switch (cmd) {
399
- case '/help':
400
- printHelp();
401
- break;
402
- case '/account':
403
- printAccount(cfg);
404
- break;
405
- case '/agents':
406
- await printAgents(cfg, rl);
407
- break;
408
- case '/permissions':
409
- printPermissions();
410
- break;
411
- case '/mcps':
412
- printMcps();
413
- break;
414
- case '/thread':
415
- printThread();
416
- break;
417
- case '/new':
418
- currentThreadId = null;
419
- console.log(chalk_1.default.dim('\n Thread reset — next message starts a new conversation.\n'));
420
- break;
421
- case '/clear':
422
- process.stdout.write('\x1Bc');
423
- printBanner(cfg);
424
- break;
425
- case '/exit':
426
- case '/quit':
427
- console.log(chalk_1.default.dim('\n Bye!\n'));
428
- rl.close();
429
- process.exit(0);
430
- break;
431
- default:
432
- console.log(chalk_1.default.yellow(`\n Unknown command: ${cmd}`));
433
- console.log(chalk_1.default.dim(' Type / to see available commands.\n'));
434
- }
435
- rl.resume();
436
- rl.prompt();
1160
+ // ── Arrow Down ────────────────────────────────────────────────────────────
1161
+ if (key?.name === 'down') {
1162
+ if (slashMode && sugg.matched.length > 0)
1163
+ moveSuggestion(1);
437
1164
  return;
438
1165
  }
439
- // ── Regular prompt send to RedAI backend ────────────────────────────────
440
- rl.pause();
441
- await sendMessage(cfg, input);
442
- rl.resume();
443
- rl.prompt();
444
- });
445
- rl.on('close', () => {
446
- console.log(chalk_1.default.dim('\n Bye!\n'));
447
- process.exit(0);
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
+ }
1186
+ // ── Backspace ────────────────────────────────────────────────────────────
1187
+ if (key?.name === 'backspace') {
1188
+ if (buffer.length === 0)
1189
+ return;
1190
+ buffer = buffer.slice(0, -1);
1191
+ if (slashMode) {
1192
+ if (buffer === '') {
1193
+ slashMode = false;
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);
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();
1217
+ return;
1218
+ }
1219
+ // ── Enter — submit ────────────────────────────────────────────────────────
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
+ }
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';
1231
+ buffer = '';
1232
+ slashMode = false;
1233
+ inputLines = 1;
1234
+ sugg.matched = [];
1235
+ sugg.selectedIdx = 0;
1236
+ sugg.renderedLines = 0;
1237
+ if (!input) {
1238
+ // Empty submit — just reprint prompt in-place, no blank line
1239
+ flush(out + PROMPT_PREFIX);
1240
+ return;
1241
+ }
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);
1247
+ return;
1248
+ }
1249
+ // ── Regular character ─────────────────────────────────────────────────────
1250
+ if (!char || char.length !== 1)
1251
+ return;
1252
+ buffer += char;
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
+ }
1261
+ if (buffer === '/') {
1262
+ slashMode = true;
1263
+ sugg.matched = [...COMMANDS];
1264
+ sugg.selectedIdx = 0;
1265
+ sugg.buffer = buffer;
1266
+ reprintInputBlock(true);
1267
+ return;
1268
+ }
1269
+ // Plain text — just append char without full reprint (no flicker)
1270
+ process.stdout.write(char);
448
1271
  });
449
1272
  await new Promise(() => undefined);
450
1273
  }